GeoLibre 技术架构与实现解析:一套 React 应用如何跑在四种宿主上
GeoLibre 技术架构与实现解析 - 我才是银古
GeoLibre 用一套 React 应用同时运行在浏览器、Tauri v2 桌面、移动端与 Jupyter 四种宿主中,桌面端以 Rust 命令桥实现完整本地 I/O。
1. 项目形态:一套 React 应用,四种宿主
GeoLibre 的代码库只维护一套 React 应用,却要同时运行在浏览器、桌面、移动端与 Jupyter 中:
| 宿主 | 构建方式 | 运行时 | 关键差异 |
|---|---|---|---|
| Web | Vite 静态构建 + nginx | 浏览器 | 无本地文件对话框、无本地 MBTiles/本地栅格读取 |
| 桌面 | Tauri v2(系统 WebView) | Rust 命令桥 + WebView | 完整本地 I/O,可拉起 Python sidecar |
| Android / iOS | Tauri v2 mobile | 系统 WebView | 功能裁剪,商店沙盒限制 |
| Jupyter | anywidget(内嵌 dist-embed) |
Python 内核 ↔ JS | 由 Python 驱动地图,双向同步 |
| R | HTML widget | RStudio/Quarto/Shiny | 交换 .geolibre.json 工程 |
选择 Tauri 而非 Electron,换来的是更小的安装体积与系统 WebView 的原生能力;代价是 WebView 差异被放大:Windows 是 WebView2(Chromium),macOS/iOS 是 WKWebView(JavaScriptCore),Linux 是 WebKitGTK。后者的合成器与 DMA-BUF 行为不同,仓库因此维护了 WEBKIT_DISABLE_DMABUF_RENDERER 一类环境变量开关,并记录了 WebKitGTK 上逐瓦片约 125 ms 的渲染成本。这类平台差异不是“配置问题”,而是跨端方案必须计入的工程成本。
2. 仓库结构:npm workspaces monorepo + 两个 Python 组件
apps/geolibre-desktop React + Tauri 主应用(src/、src-tauri/、jupyterlite/、public/)
apps/geolibre-mcp-app MCP 应用壳
packages/core Zustand store、领域类型、.geolibre.json 工程 schema
packages/map MapLibre 生命周期、图层同步、Mapbox/Cesium/ArcGIS 引擎
packages/ui shadcn 风格 UI 原语
packages/processing 客户端算法注册表(矢量/栅格/WASM/OBIA/统计/DGGS)
packages/plugins 插件接口、内置插件、面板/菜单注册表
packages/embed @geolibre/embed 类型化 iframe 客户端
packages/collab-core 协作协议与会话
backend/geolibre_server 本地 FastAPI sidecar(127.0.0.1:8765)
backend/geolibre_server_api 多用户项目/身份 API 参考实现(FastAPI + SQLAlchemy)
python/ geolibre anywidget 包 + geolibre-mcp
workers/ Cloudflare Workers:viewer/collab/tiles/ai-proxy
extensions/geolibre-chrome Chrome 扩展
包边界是这套架构最重要的约束之一。@geolibre/core 持有全部领域类型与工程 schema,@geolibre/map 只负责渲染与同步,@geolibre/processing 只注册算法,@geolibre/plugins 定义插件契约。UI 层不直接操作地图 SDK,而是修改 store。这个约束在后面几节会反复出现。
3. 状态模型:store-driven 单向数据流
应用的状态收敛在一个 Zustand store 中,数据流被固定为七步:
- 数据入口:Add Data 菜单、Tauri 对话框、浏览器文件选择、拖拽、插件控件。
- 本地矢量转换:MapLibre 读不了的格式先由 DuckDB-WASM Spatial 转为 GeoJSON(
INSTALL spatial; LOAD spatial;→ST_Read;GeoParquet 走 Parquet reader;zip Shapefile 先shpjs,失败再交给 DuckDB;KMZ 客户端解压)。 - 写入 store:转换结果交给
addGeoJsonLayer;瓦片、服务、栅格、ArcGIS、MBTiles、插件图层则生成带 source 元数据的GeoLibreLayer记录。 - 引擎订阅:选中的
MapEngine订阅layers,MapLibre 走MapController.syncLayers,另外三种引擎走各自适配器。 - 样式与面板改动:改的仍是图层状态,由 sync 落到 paint、可见性、不透明度、顺序与删除。
- 属性表选择:更新高亮要素源,可缩放地图到选中要素。
- 保存:
projectFromStore序列化为.geolibre.json。
这条链路解释了几个设计结果:
- 四种渲染引擎、分屏 pane、撤销栈、工程保存共用同一份状态,切换引擎不需要重建工程;
- 任何“直接
map.addLayer()”的写法都会绕开 sync,导致撤销、保存、分屏不同步,因此源码里明确禁止; - 插件拿到的
appAPI 只能在 store 语义内操作,无法越权改地图。
撤销栈的参数也体现了工程化取舍:historyCoalesceMs = 400(连续操作合并窗口)、maxHistoryFeatureCount = 500_000、以及按 200 字节/要素折算内存预算,避免大图层把历史记录撑爆。
4. 数据接入:分层解析,轻量优先
格式读取不是一棵“统一解析器”,而是按成本分层的:
| 类别 | 首选路径 | 兜底 |
|---|---|---|
| GeoJSON / JSON | 直接读取 | — |
| Shapefile(zip) | shpjs |
DuckDB ST_Read |
| GeoPackage / SQLite | sql.js / 专用 reader |
DuckDB |
| KML / KMZ | DOMParser + 客户端解压 | — |
| FlatGeobuf / PMTiles / COG | 专用 reader + HTTP Range | — |
| GeoParquet | Parquet reader(Arrow) | DuckDB |
| Zarr / NetCDF / HDF | 专用 reader + 维度选择 | — |
| CAD / DXF / LandXML / Excel | 专用 parser | — |
| 其它矢量 | — | DuckDB-WASM Spatial |
DuckDB-WASM Spatial 在这里同时扮演两个角色:格式驱动与计算引擎。它把文件注册成临时表再查询,因此“导入”和“SQL 查询”共用同一条路径,不需要为每种格式写两套代码。
真正决定云原生可用性的不是前端库,而是两个网络条件:CORS 与 HTTP Range。COG、PMTiles、GeoParquet 都依赖分段读取,对象存储若不返回 Accept-Ranges 或不允许跨域,浏览器端就会退化为整文件下载甚至失败。这一点在自托管与私有桶场景里尤其明显。
规模阈值同样是分层的一部分:packages/core/src/types.ts 中的 LARGE_VECTOR_FEATURE_THRESHOLD = 50_000,超过该数量的本地矢量图层不再用一个内存中的 GeoJSON source,而是切换到客户端矢量瓦片(geojson-vt 协议)。同一阈值也约束派生几何、Cesium 点要素与 Sedona 结果集,避免一次性把几十万要素塞进主线程。
5. 渲染层:四种引擎的抽象与代价
渲染抽象围绕 MapEngine 与能力矩阵展开:每个引擎声明自己支持哪些数据源、投影、3D、控件与插件能力,UI 据此隐藏或禁用入口,而不是等运行时抛错。
| 引擎 | 投影 | 三维 | 加载成本 | 典型限制 |
|---|---|---|---|---|
| MapLibre GL JS(默认) | Web Mercator、部分平面 CRS | globe/terrain | 主包内 | 不支持任意椭球投影 |
| Mapbox GL JS | Web Mercator | globe/terrain | 懒加载,约 1.91 MB(gzip 532 KB) | 需 token,许可条款不同 |
| CesiumJS | 椭球、行星 | 原生 3D 地球 | 约 4.7–4.8 MB,懒加载 | 插件控件需适配层 |
| ArcGIS Maps SDK | 多投影、3D 场景 | 2D/3D 视图 | 约 84 MB、18,810 个文件,走 CDN | 依赖 Esri CDN 与 API key |
多引擎不是“多引一个库”那么简单,成本体现在三处:
- 图层同步:同一份
GeoLibreLayer要映射到不同 SDK 的 source/layer 语义,MapLibre 的 style spec 与 ArcGIS 的 renderer 差异很大,packages/map/src里因此有layer-sync.ts、cesium-layer-sync.ts、arcgis-layers.ts等多套适配器。 - 控件宿主:MapLibre 控件在 Cesium/ArcGIS 上需要桥接层(
cesium-control-host.ts、arcgis-control-host.ts),否则插件生态无法复用。 - 能力降级:几何编辑器(GeoEditor)的插件声明
engines: ["maplibre", "mapbox"],切到 Cesium/ArcGIS 时不会激活;这不是 bug,而是宿主 API 不兼容时的显式降级。
分屏(split panes)复用同一份状态,按地面分辨率同步相机,每个 pane 可以挂不同引擎。这也是 store-driven 设计最直接的收益。
6. 空间处理:四种执行后端
地图上的“Processing”并不是一个后端,而是四条执行路径的统一入口:
| 后端 | 运行位置 | 适用 | 代价 |
|---|---|---|---|
| Client(Turf.js) | 浏览器主线程/Worker | 轻量矢量运算 | 精度受经纬度影响 |
| Pyodide | 浏览器 WebAssembly | 需要 GeoPandas/Shapely 的投影感知运算 | 首次下载几十 MB 运行时 |
| Sidecar(FastAPI) | 本机 127.0.0.1:8765 |
栅格、格式转换、GeoPandas 增强 | 需安装可选 extras |
| Whitebox WASM | 浏览器 WebAssembly Worker | 1,000+ 地理处理工具 | 受浏览器内存与单线程约束 |
Whitebox 工具箱在菜单层列出 1,068 个工具条目、9 个分类,运行时目录快照包含 775 个带参数定义的工具(两者不是同一集合:菜单还包含锁定条目与仅分类展示项)。工具定义里的 param.kind 决定它在模型构建器里的角色:raster_in/vector_in 成为输入端口,raster_out/vector_out 成为输出端口,其余进入参数面板。
sidecar 是可选组件,通过 /sidecar 同源代理接入浏览器构建,路径被限制在 GEOLIBRE_CONVERSION_ROOTS(镜像内默认 /data)。这个限制不是可选项:一个能执行任意路径读写的本地 HTTP 服务,在浏览器同源代理下等于把文件系统暴露给页面。
模型构建器(Model Builder)把上述工具串成 DAG:packages/processing/src/model-graph.ts 用 Kahn 算法做拓扑排序,校验 10 类图问题(未知端口、重复输入、悬空边等),并支持导出等价 Python 脚本。它没有迭代器,因此“批量处理”需要靠 Batch 模式或导出脚本,这一点在实现上是明确的缺失,而不是隐藏功能。
7. SQL 工作区:三种引擎的取舍
| 引擎 | 实现 | 能力 | 限制 |
|---|---|---|---|
| DuckDB Spatial | DuckDB-WASM | 读本地/远程文件、空间函数、Parquet | 单浏览器线程,内存受限 |
| PostGIS(PGlite) | PostGIS WASM | 真实 PostGIS 语义 | 运行时约 19 MB,单连接 |
| Apache Sedona | CereusDB WASM | 分布式语义的本地近似 | 结果集上限 50,000 要素 |
DuckDB 路径的注册方式是“每次查询重建临时表”:把图层 FeatureCollection 写入内存文件系统,CREATE OR REPLACE TEMP TABLE ... AS SELECT * FROM ST_Read(...),查询结束即销毁。这样查询永远反映当前图层状态,代价是每次都要重新解析。结果表默认只渲染前 500 行,历史保留 25 条,避免大结果拖垮 UI。
8. 工程格式与序列化边界
.geolibre.json 是唯一事实源,顶层包含 version、layers、layerGroups、pluginState、legend、printLayout、storymap、widgets、bookmarks、interaction 等块。图层对象支持多种类型(矢量、栅格、瓦片、服务、3D、点云、插件图层等),样式、弹窗、连接、虚拟字段、快速过滤器都挂在图层上。
格式设计里有两个细节值得注意:
- 凭据脱敏:工程文件只保存凭据引用,不保存明文密钥;分享前还有一层 share-readiness 检查。
- 序列化上限:V8 的最大字符串长度约 536 MB,超过会抛
RangeError;JavaScriptCore 与 SpiderMonkey 的报错文案又不同。project-serialization-limits.ts因此按错误文案匹配,并把“工程过大”引导到 PMTiles/FlatGeobuf,而不是让用户看到一个无上下文的失败。导入类文件(QGIS 工程、ArcGIS Pro 工程、插件工程)另有 25 MiB 的上限。
9. 插件体系:契约、生命周期与边界
插件接口 GeoLibrePlugin 描述 id、名称、版本、engines、activate/deactivate,以及可选的右侧栏面板、工具栏菜单、浮动面板、地图点击工具、助手工具等声明。激活流程由 plugin-manager.ts 统一编排:注册 → 能力检查 → 引擎匹配 → 建立作用域 API(scopeAppToPlugin)→ 调用 activate;失败时按相反顺序回滚。
外部插件的来源有三种:zip 包(校验 SHA-256)、plugin.json 清单 URL、以及构建时烘进 public/plugins/<id>/ 的 bundled drop-in。市场(marketplace)只是清单的分发层,安装仍走同一套校验与信任流程。插件不能绕过部署策略:部署策略里的 plugins 段与 capability(如 plugins:install)会在加载前裁决。
插件能做什么、不能做什么,由 app API 的边界决定:可以增删图层、读写样式、注册面板与菜单、发起 OAuth、保存凭据;但不能直接持有 MapLibre 实例,也不能绕过 store 修改工程。这个边界让插件在多引擎、分屏、撤销、保存四个维度上都不会破坏一致性。
10. AI、MCP 与脚本化
三条 AI 相关路径在实现上完全不同:
- AI 助手:自然语言 → 工具调用 → 应用命令通道。所有操作都经过应用自己的命令层,因此可审计、可撤销;Provider 采用 BYOK,支持 Gemini、Anthropic、OpenAI、OpenRouter、Vercel AI Gateway、Amazon Bedrock、本地 Ollama 与任意 OpenAI 兼容端点。
- MCP 服务器:
geolibre-mcp是 stdio 服务,无头地读写.geolibre.json。project.py负责构造片段(图层、插件状态),authoring.py负责把片段应用到整份工程,server.py是唯一导入 MCP SDK 的模块。工作区被限制在GEOLIBRE_MCP_ROOTS或--root内。 - agent skill:一段
SKILL.md,教外部编码智能体“何时用哪条路、哪些规则容易踩坑”,不参与运行时。
Python 侧还有两个入口:应用内 Python Console 跑在 Pyodide 上,Notebook 面板在桌面端连本地 JupyterLab(relay 端口 8766),在 Web 端用 JupyterLite。geolibre Python 包则以 anywidget 形式内嵌完整应用,实现 Python 与地图的双向同步。这些路径最终都落到同一份工程格式或同一套命令上,这是它们能互相组合的原因。
11. 服务端拓扑:可选,而非必需
系统里有四个进程/服务,职责严格分离:
| 组件 | 默认地址 | 作用 | 是否必需 |
|---|---|---|---|
| Python sidecar | 127.0.0.1:8765 |
栅格/转换/GeoPandas 增强 | 可选 |
| Server API | :8000 |
多用户项目、身份、分享、OAuth2 | 可选 |
| Collab relay | :8787 |
WebSocket 实时协作 | 可选 |
| Notebook relay | 127.0.0.1:8766 |
桌面端 Jupyter 桥 | 可选 |
Server API 是 FastAPI + SQLAlchemy,默认 SQLite,可切 PostgreSQL;对象存储默认本地目录,也可用 S3 兼容存储。身份体系包括个人令牌(带 scope)、账号/会话、组织与组、OIDC 单点登录、SCIM 2.0、可信头代理登录,以及 OAuth 2.0 Authorization Code + PKCE。协作中继在托管环境用 Cloudflare Durable Object(每会话一个实例),自托管用 workers/collab-node 的 Node + ws 实现;协议是“整快照 + 增量补丁”,带回声抑制与撤销交互处理。
Docker Compose 把 Web(8080)、Server(8000)、Collab(8787)与 Postgres 串起来,Web 镜像同时承担静态资源与 /sidecar 反向代理。部署策略(deployment.json)提供六项能力开关:project:edit、data:add、processing:run、export:data、plugins:install、settings:manage,用于 kiosk、教室、只读查看等场景;UI Profiles 则是另一套更轻的界面裁剪,两者不互相替代。
12. 性能工程:阈值、懒加载与缓存
这套应用的性能策略基本可以概括为“用明确的数字决定实现路径”:
| 阈值 | 值 | 位置/作用 |
|---|---|---|
| 客户端矢量切片 | 50,000 要素 | LARGE_VECTOR_FEATURE_THRESHOLD |
| 撤销栈上限 | 500,000 要素 / 200 B 折算 | history.ts |
| 撤销合并窗口 | 400 ms | historyCoalesceMs |
| 启动包预算 | 3 MiB raw / 900 KiB gzip | boot-budget.json |
| SQL 结果渲染 | 500 行 | SQL 工作区 |
| 工程导入 | 25 MiB | QGIS/ArcGIS/插件工程 |
| Sedona 结果 | 50,000 要素 | MAX_CEREUS_FEATURES |
| 诊断日志 | 500 条 / 3000 字符 | Diagnostics |
启动包预算由 CI 守住,bundle:report 用于定位体积来源;Cesium、Mapbox、ArcGIS 都按需懒加载,代价是首次切换引擎有明显等待。属性表用 @tanstack/react-virtual 只渲染可视行;PWA 用 Workbox 做三层缓存(应用壳、CDN 运行时、瓦片/离线区域)。Linux 上的 WebKitGTK 渲染成本另有一套环境变量与降级策略,属于平台特例而不是通用优化。
13. 安全与隐私的边界
“本地优先”在实现上是一组约束,而不是一句承诺:
- 数据默认不出浏览器:分析、SQL、WASM 工具都在客户端执行;只有主动添加远程 URL 或分享工程时才发生网络上传。
- CSP 双处同步:桌面
tauri.conf.json与 Webdocker/nginx.conf各自维护脚本白名单,tests/tauri-csp.test.ts会重新推导并校验二者一致,避免只改一边。 - 凭据不落工程:工程文件只存凭据引用;凭据本身走系统钥匙串或本地加密存储。
- 本地服务收敛:sidecar 绑定回环地址,浏览器构建通过同源
/sidecar代理并限制路径;MCP 限制根目录;协作中继的权限与身份是显式模型。 - 部署能力闸门:能力开关在路由与 UI 两侧同时生效,只读查看模式不会因为前端绕过而获得写权限。
14. 工程质量门
GeoLibre 的 CI 不是“跑一遍测试”,而是一组带棘轮的约束:
- 前端:
node --test测试套件 + 覆盖率地板 78% lines / 78% branches / 63% functions; - 后端:
pytest+ 覆盖率地板 55%; - 端到端:Playwright 分 core/features 两类项目;
- 静态检查:ESLint 告警上限 242、TypeScript 全量构建、
cargo check校验 Tauri crate; - 棘轮:未测模块基线、测试类型错误数、lint 告警数只允许下降;
- 生成物一致性:Whitebox 菜单目录、i18n 工具元数据、插件文档、示例画廊都必须与源码同步重新生成,否则 CI 失败。
国际化使用 react-i18next,en.json 是唯一源,类型由 i18next.d.ts 约束;RTL 语言要求使用逻辑方向工具类(ms-/me-/ps-/pe-),物理方向类由自定义 lint 规则拦截。发布侧覆盖 Web、Windows、macOS(含沙盒版)、Linux 六种包格式、Android、iOS、Jupyter wheel、R 包与 Chrome 扩展,各平台的裁剪在构建期决定,而不是运行时探测。
15. 局限与适用边界
从源码可以明确读出的限制:
- 桌面专属能力:本地文件对话框、本地 MBTiles、本地栅格读取、工程直接落盘依赖 Tauri,Web 端不可用。
- 引擎能力不对等:MapLibre 只支持 Web Mercator 类投影;GeoEditor 只在 MapLibre/Mapbox 激活;部分控件在 Cesium/ArcGIS 上需要适配层。
- 浏览器资源上限:WASM 工具与 DuckDB 共享浏览器内存,多 GB 数据仍应交给桌面 GIS 或服务端。
- 云原生依赖网络条件:没有 CORS 与 Range 支持的对象存储,COG/PMTiles/GeoParquet 的“按需读取”优势无法成立。
- 模型构建器无迭代器:批量任务要靠 Batch 或导出的 Python 脚本。
- 不是企业级地理数据库:版本化编辑、复制、企业级事务不在范围内。
- 移动端与商店沙盒:部分能力(如 sidecar、Earth Engine 登录)在移动端或 Mac App Store 构建中被移除。
这些限制并不影响它作为“浏览器优先的轻量工作台”的定位,但决定了它不适合承担重服务端计算或企业级数据治理。
16. 小结
GeoLibre 的架构可以概括为三层:可序列化的单一状态源(Zustand + .geolibre.json)、按能力抽象的多渲染后端(MapLibre/Mapbox/Cesium/ArcGIS)、按成本分层的执行后端(Turf → Pyodide → sidecar → WASM)。它的工程价值不在于功能数量,而在于把“跨端、多引擎、可撤销、可分享”这四个约束同时放进了一套状态模型里,并用阈值、能力矩阵和 CI 棘轮把边界固定下来。
来源:博客园 · cnblogs.com