Appearance
核心概念
编辑器三包围绕一个单一真源引擎展开:所有交互最终都翻译成命令,命令产出统一的变更包,引擎据此应用、记录历史、通知渲染。理解这条数据流即理解整个编辑器。
交互(适配器) → 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[] }类型校验(三机制)
不同类别数据先做几何类型校验,避免误操作:
- 适用性硬拦截——每个命令声明
appliesTo(如SplitLineCommand仅LineString),在plan()开头校验操作数;不匹配抛EditError(code='TYPE_MISMATCH')。 - 同质性校验——合并/多选时混选不同几何类型抛
EditError(code='HETEROGENEOUS')。 - 类别↔几何一致性——可选规则
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…顶点句柄显示
顶点句柄是一种操作暗示:画一个可抓取样式的圆点,等于承诺“这里能拖”。因此句柄按当前模式 + 选择集分三档显示,避免「看着能拖实则无效」的虚假暗示,两个适配器行为一致:
| 档位 | 模式 | 表现 |
|---|---|---|
active | modify / add-vertex / delete-vertex | 实心句柄,可拖可点 |
passive | select / move / transform | 更小更淡的「幽灵」句柄,仅展示结构、不可拖 |
none | draw-* / 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 累积未提交的 ChangeSet,commit() 时打包提交;它与地图库无关,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」这类本可并存的情形。给 SyncClient 配 conflictResolver 即启用三路合并:以本地编辑起点几何为共同祖先 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 时沿用旧的二路覆盖,向后兼容。
离线重试队列
SyncScheduler 在 commit() 之上补「队列 / 指数退避重试 / 离线态 / 自动 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); // 恢复在线自动 flushtransport 抛出的瞬时错误(网络/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-core(computeGuides/snapToGuides/guideIntersection/clipGuideToRect,纯 2D、库无关),渲染与吸附在两适配器:OL 复用原生Snap吸附辅助线段与交点;Libre 自绘并走同一 datum 出站变换,国内偏移底图上不漂移。 - 与既有顶点/边吸附组合:顶点/边优先,其次辅助线。
下一步
- OpenLayers 适配器 · MapLibre 适配器
- 编辑器 API 参考 —— 全部命令构造签名与类型
