Skip to content

核心概念

编辑器三包围绕一个单一真源引擎展开:所有交互最终都翻译成命令,命令产出统一的变更包,引擎据此应用、记录历史、通知渲染。理解这条数据流即理解整个编辑器。

交互(适配器) → Command → ChangeSet → EditEngine(唯一真源)
                                          │ 校验 → 应用 → 入撤销栈 → emit 快照

                                    适配器 render(snapshot)

EditEngine 与 EditSession

EditEngine 是无地图依赖的状态机:持有要素、撤销/重做栈、校验规则;对外只认 dispatch(command) / undo() / redo(),并通过 on(snapshot => …) 推送快照。

EditSession 是易用门面,在引擎之上再聚合选择集剪贴板吸附索引,并用 attach(adapter) 一步绑定交互与渲染:

ts
const session = new EditSession({ crs, features, rules });
session.engine.dispatch(/* command */);
session.select(['a', 'b']); // 选择集(变换/合并/复制默认作用于此)
session.copy(); // 复制选中到剪贴板
session.paste([dx, dy]); // 粘贴(带偏移,可撤销)
const off = session.attach(adapter); // 返回解绑函数

命令与 ChangeSet(撤销/重做零特判)

每个命令是纯函数plan(ctx) 只“算出这次产生什么变更”,返回一个 ChangeSet,不直接改状态。

ts
interface ChangeSet {
  txId: string;
  label: string; // 直接显示在撤销菜单,如「打断」「合并」
  added: EditableFeature[];
  removed: EditableFeature[]; // 含完整快照,供撤销还原
  modified: { id: FeatureId; before: Geometry; after: Geometry }[];
}

引擎对 added/removed/modified 三元组做泛型的正向/逆向应用,因此打断(1→2)、合并(2→1)、移动(1→1)共用同一撤销逻辑,零特判。新增一个工具 = 再写一个命令,引擎主干不动。

ts
const res = session.engine.dispatch(new SplitLineCommand('road-1', [x, y]));
// res: { ok: boolean; issues: ValidationIssue[] }

类型校验(三机制)

不同类别数据先做几何类型校验,避免误操作:

  1. 适用性硬拦截——每个命令声明 appliesTo(如 SplitLineCommandLineString),在 plan() 开头校验操作数;不匹配抛 EditError(code='TYPE_MISMATCH')
  2. 同质性校验——合并/多选时混选不同几何类型抛 EditError(code='HETEROGENEOUS')
  3. 类别↔几何一致性——可选规则 featureTypeConsistency(typeMap)properties.featureType 与实际几何不符 → warning

EditError 会被 dispatch 捕获并转为 EditResult.issues,因此类型不符时状态不变、问题回传 UI 标红,不会抛断流程:

ts
const res = session.engine.dispatch(
  new SplitPolygonByLineCommand('road-1', cutLine),
);
if (!res.ok) console.warn(res.issues[0].message); // 「面拆分」不适用于 LineString…

顶点句柄显示

顶点句柄是一种操作暗示:画一个可抓取样式的圆点,等于承诺“这里能拖”。因此句柄按当前模式 + 选择集分三档显示,避免「看着能拖实则无效」的虚假暗示,两个适配器行为一致:

档位模式表现
activemodify / add-vertex / delete-vertex实心句柄,可拖可点
passiveselect / move / transform更小更淡的「幽灵」句柄,仅展示结构、不可拖
nonedraw-* / split / split-polygon / merge不显示既有顶点
  • 限定到选择集——有选择时只显示选中要素的句柄(聚焦编辑);无选择时 active 模式回退显示全部(允许不先选中直接编辑顶点),passive 模式不显示。adapter.setSelection(ids) 会即时刷新句柄。
  • 拖拽期隐藏——整体平移 / 变换手柄拖拽期间隐藏静态句柄,避免与几何预览错位(OL 顶点拖拽由 ol/Modify 自绘跟随句柄;libre 自绘顶点拖拽时句柄随之移动)。

句柄坐标由 editor-core 的纯函数 collectVertexHandles(features) 统一抽取(每个句柄 properties 携带 VertexHandleRef 定位),OL/MapLibre 两端共用,保证句柄位置与多边形闭合语义完全一致——与吸附内核同源同理。视觉层级(active/passive)由各适配器另行决定。

吸附

吸附逻辑在核心(基于 rbush),保证 OL/MapLibre 手感一致。适配器经 snapCandidates(near, tolPx) 喂入视口顶点/线段,核心在像素容差(经 resolutionAt 换算地图单位)内找最近目标:

ts
session.loadSnapTargets(adapter.snapCandidates(near, 8));
const hit = session.resolveSnap(near, tol); // { coord, distance, target }

拓扑校验(前端 UX,后端权威)

校验规则可插拔,声明作用域与严重级别:

ts
interface ValidationRule {
  id: string;
  severity: 'error' | 'warning';
  scope: 'local' | 'server';
  check(cs: ChangeSet, ctx: EditContext): ValidationIssue[];
}

本地(前端)内置规则:自相交、零长线、重复顶点等(defaultLocalRules())——error 阻止并标红、warning 放行提示。前端校验只是即时 UX,不作结论;权威拓扑在后端重跑。

后端同步(可选)

SyncClient 累积未提交的 ChangeSetcommit() 时打包提交;它与地图库无关,OL/MapLibre 两轨通用。

ts
const sync = new SyncClient(session.engine, new MemoryEditBackend());
const out = await sync.commit();
// out.status: 'noop' | 'committed' | 'conflict'

三个要点:

  • 乐观并发——提交载荷在 ChangeSet[] 外携带 baseVersions(≡ ETag/If-Match)。服务端版本不一致返 409,客户端 adoptServerState(conflicts) rebase。
  • ID 对账——新建要素提交时为临时 id,回包 idMap 后引擎把状态与历史栈里的 id 一并替换为真实 id。
  • 版本回写——成功后写回各要素新版本,作为下次提交基线。

MemoryEditBackend 是内存版权威后端(联调/测试用);真实 PostGIS / WFS-T 实现 EditTransport 接口即可接入。

三路合并(base / local / remote)

二路覆盖在 409 时整体丢弃本地编辑,会误伤「本地改 A、服务端只动 B」这类本可并存的情形。给 SyncClientconflictResolver 即启用三路合并:以本地编辑起点几何为共同祖先 base(恰好是 ChangeSet.modified.before),逐要素判定——服务端仅版本自增的本地编辑 rebase 后自动重提交、非重叠编辑并存,双方真分歧才交 resolver 裁决。

ts
import {
  SyncClient,
  remoteWinsResolver,
  localWinsResolver,
} from '@geoverse/editor-core';

const sync = new SyncClient(session.engine, backend, {
  conflictResolver: remoteWinsResolver, // 真分歧时采纳服务端(无损权威);或 localWinsResolver
});
const out = await sync.commit();
// committed 时 out.merge 给出各冲突要素的 base/local/remote 与裁决(含 diverged 真冲突清单)

不配 conflictResolver 时沿用旧的二路覆盖,向后兼容。

离线重试队列

SyncSchedulercommit() 之上补「队列 / 指数退避重试 / 离线态 / 自动 flush」:

ts
import { SyncScheduler } from '@geoverse/editor-core';

const scheduler = new SyncScheduler(sync, {
  maxRetries: 5,
  baseDelayMs: 500, // 指数退避 500 → 1000 → 2000 …(封顶 maxDelayMs)
  onStateChange: (s) => setBadge(s), // 'idle' | 'pending' | 'syncing' | 'retrying' | 'offline' | 'error'
});

scheduler.setOnline(false); // 离线:flush 挂起为 queued,变更不丢
await scheduler.flush(); // → { status: 'queued' }
await scheduler.setOnline(true); // 恢复在线自动 flush

transport 抛出的瞬时错误(网络/5xx)按退避重试;conflict 是领域结果不重试。sleep 可注入,便于测试。

WFS-T 适配器

WfstTransport 实现 EditTransport,把 EditSubmission 译成 WFS-T 1.1.0 事务(Insert/Update/Delete + GML3 几何)发往真实 WFS/PostGIS 后端。纯 TS、无地图库与无 DOM 依赖。

ts
import { WfstTransport, isWfstTransient } from '@geoverse/editor-core';

const transport = new WfstTransport({
  url: 'https://gis.example/geoserver/ows',
  typeName: 'topp:edits',
  featureNS: 'http://www.openplans.org/topp',
  featurePrefix: 'topp',
  geometryName: 'the_geom',
  srsName: 'EPSG:4326',
  axisOrder: 'xy', // 轴序坑:WFS 1.1.0 + urn:...:EPSG::4326 需 'yx'
  versionProperty: 'rev', // 可选乐观锁:Filter 追加版本断言
  loadFeatures: (ids) => fetchCurrent(ids), // 冲突时重建 409 服务端状态
});
const sync = new SyncClient(session.engine, transport, {
  conflictResolver: remoteWinsResolver,
});
// 配 SyncScheduler 时用 isTransient: isWfstTransient 让 5xx/网络错误退避重试

WFS-T filter 式乐观锁跨冲突边界非原子;生产环境如需严格原子性建议改用 GetFeatureWithLock + lockId。本适配器以参考实现为主,异类服务端可能需按方言微调。

对齐辅助线(Snap guide)

绘制折线/多边形时,可显示对齐辅助虚线并吸附到它们,实现精确对齐(参考 ol-ext SnapGuides)。辅助线由草图已落点算得:过首点与末点的水平/垂直线、末段的延长线正交线;两条辅助线的交点也可吸附。

ts
// OL:new OlEditorAdapter(map, { snapGuide: true })
// Libre:new LibreEditorAdapter(map, { snapGuide: true })
adapter.setSnapGuide(true); // 运行时开关
  • 几何算法在 @geoverse/editor-corecomputeGuides / snapToGuides / guideIntersection / clipGuideToRect,纯 2D、库无关),渲染与吸附在两适配器:OL 复用原生 Snap 吸附辅助线段与交点;Libre 自绘并走同一 datum 出站变换,国内偏移底图上不漂移。
  • 与既有顶点/边吸附组合:顶点/边优先,其次辅助线。

下一步