diff --git a/.trellis/tasks/08-26-direct-edit-map-editor/research/client-synthesised-range-handles.md b/.trellis/tasks/08-26-direct-edit-map-editor/research/client-synthesised-range-handles.md new file mode 100644 index 0000000..643abcb --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-map-editor/research/client-synthesised-range-handles.md @@ -0,0 +1,63 @@ +# 区间范围手柄由客户端合成 + +第 4 步的实现决定,与 `design.md`「Handle manifest」小节的字面规则有偏离,故单独记录。 +决定人:dingkang,2026-08-27。 + +## 偏离了什么 + +`design.md` 写明: + +> 服务端从同一语义模型生成手柄清单,**客户端不自行推导手柄位置或可拖方向**。 + +而区间范围手柄(第 4 类可拖能力)由客户端 `selection.ts` 的 `intervalRangeHandles()` 合成, +位置由 `meters.ts` 的 `coordinateAtStation()` 从中心线插值得到。 + +## 为什么这样定 + +**1. manifest schema 装不下它。** +`EditHandle.kind` 的类型是 `RoadConstraintKind`,而 `design.md` 的 kind 表明确声明 +「这 6 个 kind 与 PRD 首期范围一一对应,没有多余项也没有缺口」。 +范围手柄不是一种约束——它改的是既有约束 `anchor` 的 `startStation` / `endStation`。 +要下发就得加第 7 个求解器根本不认的假 kind,或加一个平行数组。为派生数据改合约不划算。 + +**2. 它的位置是服务端已发数据的纯函数。** +区间来自服务端算好并下发的 `anchor.startStation` / `endStation`, +合法窗口来自 `manifest.reserves`,拖拽轴是道路切线。 +服务端下发等于把自己刚发的东西再算一遍回显,正是 `cross-layer-thinking-guide.md` +警告的「derived state 另立第二个游标」。 + +**3. 决定性的一条:客户端本来就需要 station → 坐标的插值。** +`design.md` 要求 ghost 画「半透明预估轮廓」,即在道路上标出受影响的区间带。 +画这条带子必须把 station 插值成坐标,**与范围手柄由谁产出无关**。 +`coordinateAtStation()` 因此是客户端的既有需求;有了它,合成范围手柄几乎免费, +服务端改动买不到任何东西。 + +**4. `projectIntervalEnd()` 已交付并测试**,签名恰好就是这些输入。 + +## 为什么认为符合规则的意图 + +那条规则防的是客户端发明**语义**——哪个约束、哪个方向、什么范围合法。 +范围手柄一样都没发明: + +| 语义 | 来源 | +| --- | --- | +| 合法窗口 | `manifest.reserves`(服务端) | +| 区间当前值 | 约束 `anchor` 的 station(服务端) | +| 拖拽轴 | 道路中心线切线(编译模型,服务端) | +| 屏幕位置 | 客户端插值 ← **仅此一项是派生的** | + +客户端只做「把服务端已选定的 station 插值成屏幕位置」这一件事。 + +## 已接受的代价 + +`coordinateAtStation()` 是 `src/compile/direct-edit-solver.js` 里 `coordinateAt()` 的 +第二份实现。二者都用弧长插值,但分属不同 runtime(CommonJS 服务端 / ESM 浏览器), +且客户端那份只服务于 ghost 渲染,不参与任何持久化或求解。 +`meters.ts` 的注释已标明这层关系,防止后来者误以为可以随意改动其中一份。 + +## 若要改回服务端下发 + +新增 manifest 字段 `rangeHandles: IntervalRangeHandle[]`(不要塞进 `handles`), +由服务端用既有 `coordinateAt()` 算位置。客户端删掉 `intervalRangeHandles()` 即可, +`projectIntervalEnd()` 与拖拽链路不受影响。 +`coordinateAtStation()` 仍需保留,因为 ghost 的区间带还要用。 diff --git a/.trellis/tasks/08-26-direct-edit-map-editor/research/interval-not-applied.md b/.trellis/tasks/08-26-direct-edit-map-editor/research/interval-not-applied.md new file mode 100644 index 0000000..fbb47c9 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-map-editor/research/interval-not-applied.md @@ -0,0 +1,60 @@ +# 几何编译器不施加 `road-interval` 区间 + +第 4 步手测时发现:区间范围手柄拖动有 ghost 反馈,但松手后几何毫无变化。 +经排查这是服务端的能力缺口,不是客户端拖拽逻辑的问题。 + +## 事实 + +`grep "\.interval\b" src/compile/native-road.js` 返回空。 + +求解器写入了区间与过渡,几何阶段一个都不读: + +| profile 字段 | solver 写入 | `compileGeometry()` 读取 | +| --- | --- | --- | +| `edgeOffsets.left/right` | ✅ | ✅ `centerlineShift`(native-road.js:825) | +| `widthMeters` | ✅ | ✅ native-road.js:832 | +| `sidewalkWidths.left/right` | ✅ | ✅ 真实宽度,`sidewalkRing`(native-road.js:1666-1671) | +| `laneDividerOffsets` | ✅ | ✅ native-road.js:1946 | +| **`interval`** | ✅ direct-edit-solver.js:274-277、418 | ❌ **零消费者** | +| **`transitions`** | ✅ direct-edit-solver.js:293、303、322 | ❌ **零消费者** | + +## 后果 + +1. **每一次直接编辑都作用于整条路。** `anchor.startStation` / `endStation` 被存储、被 + `validateEditDocument()` 校验、被带进 `resolveDirectEditConstraints()` 的结果,然后被忽略。 +2. **区间范围手柄在几何上不可能有效果。** 客户端的 `intervalRangeHandles()`、 + `projectIntervalEnd()`、`coordinateAtStation()` 都正确且有单测,但下游无人接收。 +3. **`transition: 'smoothstep' | 'linear'` 是死字段。** 父任务 design.md 承诺 + 「作用于 interval 两端回归基线的过渡段」,实际没有任何过渡。 + +## 已采取的处置 + +`workbench/client/src/edit/flag.ts` 增加 `intervalEditingSupported = false`, +范围手柄的 UI 据此隐藏。纯逻辑与测试全部保留。 + +理由:一个拖起来有反馈、松手却没效果的控件比没有这个控件更糟——它会持续产生 +bug 报告,并让人怀疑整个编辑器的其余部分。区间生效的那个提交把这个常量翻成 `true` 即可, +客户端不需要其他改动。 + +## 对验收标准的影响 + +父任务 prd.md 的这条**当前无法通过**: + +> 区间范围手柄可修改影响区间,两端平滑过渡回基线。 + +`08-26-direct-edit-map-editor/prd.md` 的同名条目同理。这不是客户端欠工, +而是需要 `compileGeometry()` 具备按 station 施加横断面 profile 的能力。 + +## 若要实现(未排期) + +用户 2026-08-27 判断影响不大,故未开任务,仅记录。真要做时的形状: + +1. `native-road.js` 的横断面生成需要接受「沿中心线变化的 profile」而非单一常量宽度。 + 目前 `applyDirectEditProfiles()`(约 818-838 行)返回的是整条路一个 `widthMeters` + 和一次 `offsetLine()` 整体平移,没有沿 station 变化的余地。 +2. 区间两端按 `transitions[kind]` 做 smoothstep / linear 插值回基线值。 +3. 车道线、标线、connector、步行带都由横断面派生,必须一并跟随,否则会脱节 + (父任务 research/joint-solver.md 的依赖链)。 +4. `test/fixtures` 基线会变化,需要显式 update-baseline 并人工核对。 + +这是 `compileGeometry` 的实质改动,属已归档的 `direct-edit-solver-api` 任务范围。 diff --git a/.trellis/tasks/08-26-direct-manipulation-road-editor/task.json b/.trellis/tasks/08-26-direct-manipulation-road-editor/task.json index cddee84..718b13d 100644 --- a/.trellis/tasks/08-26-direct-manipulation-road-editor/task.json +++ b/.trellis/tasks/08-26-direct-manipulation-road-editor/task.json @@ -22,7 +22,8 @@ "08-26-direct-edit-documents", "08-26-direct-edit-solver-api", "08-26-direct-edit-map-editor", - "08-26-direct-edit-junction-tools" + "08-26-direct-edit-junction-tools", + "08-27-junction-dominated-roads" ], "parent": null, "relatedFiles": [ diff --git a/.trellis/tasks/08-27-junction-dominated-roads/check.jsonl b/.trellis/tasks/08-27-junction-dominated-roads/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-27-junction-dominated-roads/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-27-junction-dominated-roads/implement.jsonl b/.trellis/tasks/08-27-junction-dominated-roads/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-27-junction-dominated-roads/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-27-junction-dominated-roads/implement.md b/.trellis/tasks/08-27-junction-dominated-roads/implement.md new file mode 100644 index 0000000..1d3f734 --- /dev/null +++ b/.trellis/tasks/08-27-junction-dominated-roads/implement.md @@ -0,0 +1,68 @@ +# 实施计划 + +轻量任务,一个提交即可。门禁不过就停。 + +前置:无。客户端置灰渲染路径已由 `08-26-direct-edit-map-editor` 第 3 步交付并有单测覆盖, +本任务只补服务端输入。 + +## 关键决定:置灰只影响手柄,不影响求解 + +`editable: false` 只改变 handle manifest,**不改变 `solveConstraints()` 的行为**。 +落在被置灰 segment 上的既有约束仍然照常应用、照常参与几何编译。 + +理由:否则本次改动会让升级后的编译静默丢掉用户已保存的编辑——一个 UI 可编辑性的判断 +不该回溯否决已持久化的意图。用户想撤销这类编辑,走 undo / 禁用约束的正常路径。 + +这也让「几何输出逐字节不变」这条验收标准成立,从而使本步骤可安全回滚。 + +## 1. 判据与置灰 + +- 目标:可编辑带短于道路宽度的 segment,其全部 `road-*` 手柄 `editable: false`。 +- 位置:`src/compile/direct-edit-solver.js` 的 `makeRoadHandles()`。 + 该函数已算出 `start` / `end` / `length` / `width`,判据是纯本地计算,不需要新数据。 +- 实现要点: + - 带长米数 `(end - start) * length`,与 `width`(该 segment 双向宽度之和)比较。 + - 复用既有 `baseDisabled` 机制与既有文案「该道路全部位于路口保留区,请进入 JunctionTools 编辑。」, + 不新增第二套提示语。 + - 保留原 `unavailable` 判断作为兜底,两者取或。 + - 被置灰的 segment 仍然产出手柄(位置照旧),只是 `editable: false` 且带 `disabledReason`。 +- 不改:`junctionReserves()` 的 0.45 上限、cutback 推导、`solveConstraints()`、junction 手柄。 + +## 2. 测试 + +- 位置:`test/direct-edit-solver.js`。 +- 覆盖: + - 短路段(带长 < 宽度)的全部 `road-*` 手柄 `editable: false` 且 `disabledReason` 非空。 + - 正常路段 `editable: true` 且无 `disabledReason`。 + - 判据边界:带长略小于 / 略大于宽度两侧各一例。 + - junction 手柄仍 `editable: true`。 + - 置灰 segment 上的既有约束仍被求解(`constraintStates` 里 `applied: true`), + 证明置灰没有回溯否决已保存编辑。 + +## 3. 验证 + +```bash +npm run test # 含 test/direct-edit-solver.js +npm run test:client # 客户端类型 +npm run test:client:unit # 客户端纯逻辑 +npm run format:check +npm run build +``` + +几何不变的实测(本任务的核心安全性证明):对 `test/fixtures/fengshu-er-road.osm` +在改动前后各跑一次 `compileGeometry`,比较全部输出图层的 JSON,必须完全一致。 +`npm run test` 里的 fixture 基线测试已覆盖这条,若基线有 diff 即为回归。 + +手测(可选,`directEdit` 开关打开):选中 `test/fixtures` 里那 6 段短路之一, +应看到灰色手柄且 header 显示原因。这是父任务 prd 那条验收标准第一次真正可演示。 + +## 4. 门禁 + +- 几何输出零变化(fixture 基线无 diff)。 +- 正常路段行为与当前 main 完全一致。 +- 既有约束不因置灰而失效。 + +## 回滚点 + +单文件单函数改动,`git revert` 即可回到当前行为。 +客户端不需要任何配合改动,回滚后灰色手柄自然消失,回到「空真」状态。 diff --git a/.trellis/tasks/08-27-junction-dominated-roads/prd.md b/.trellis/tasks/08-27-junction-dominated-roads/prd.md new file mode 100644 index 0000000..2841170 --- /dev/null +++ b/.trellis/tasks/08-27-junction-dominated-roads/prd.md @@ -0,0 +1,87 @@ +# 路口主导的短路段整条置灰 + +父任务:`.trellis/tasks/08-26-direct-manipulation-road-editor`。约束模型与编辑所有权的权威定义在父任务 `design.md`。 + +## 问题 + +`src/compile/direct-edit-solver.js` 里 `editable: false` 的唯一来源是 `unavailable`: + +```js +const start = Math.min(1, startReserve); +const end = Math.max(0, 1 - endReserve); +const unavailable = start >= end; +``` + +但 `junctionReserves()` 给每端保留区的比例有硬上限 `Math.min(0.45, cutback / length)`, +于是 `start ≤ 0.45`、`end ≥ 0.55`,`start >= end` 结构上永远为假。 +**该分支是死代码,连带那句提示语「该道路全部位于路口保留区,请进入 JunctionTools 编辑。」永远不可能显示。** + +实测 `test/fixtures/fengshu-er-road.osm`:126 个道路手柄,0 个 `editable: false`。 + +后果是短路段拿到了一条无意义的编辑带(`test/fixtures/fengshu-er-road.osm`,20 个有保留区的段): + +| 路长(米) | 总宽(米) | 窗口 | 可编辑带(米) | +| --- | --- | --- | --- | +| 14.5 | 9.8 | [0.45,0.55] | 1.5 | +| 15.4 | 9.8 | [0.45,0.55] | 1.5 | +| 18.6 | 9.8 | [0.45,0.55] | 1.9 | +| 19.1 | 9.8 | [0.45,0.55] | 1.9 | +| 19.2 | 9.8 | [0.45,0.55] | 1.9 | +| 19.6 | 6.5 | [0.45,0.55] | 2.0 | +| 82.2 | 9.8 | [0.17,0.83] | 54.9 | +| 199.2 | 9.8 | [0.00,0.93] | 185.6 | + +第一行那条路只有 14.5 米长、9.8 米宽,cutback 是 `max(宽度) × 1.4 ≈ 13.7` 米—— +**每一端**的路口都要这条路的 94%,两端合计 188%,远超全长。上限把它压成各 45%, +凭空造出中间 10%(1.5 米)的「可编辑带」。 + +这违反两条已写明的约定: + +- 父任务不变量「相邻 profile 之间必须有可计算的过渡」。约束默认 `transition: 'smoothstep'` + 作用于区间两端回归基线,1.5 米的区间两侧紧贴保留区,没有任何余量做过渡。 +- 父任务「编辑所有权」:主地图只编辑两个 junction reserve **之间**的道路内部 interval。 + 这种路的真实路口几何占满全长,根本不存在「之间」。 + +## 目标 + +让路口几何占满的短路段整条归 JunctionTools,恢复那个分支的可达性, +并使父任务 prd 的验收标准「落在 junction reserve 内的手柄不可拖动,并提示进入 JunctionTools」 +从空真变成可演示。 + +## 判据 + +**可编辑带长度(米)< 道路总宽度(米)→ 该 segment 的所有道路手柄 `editable: false`。** + +用物理长度而非归一化 station,理由是过渡段需要的是实际距离,与道路长短无关。 +一条比自身宽度还短的编辑带装不下横断面的平滑变化。 + +在上表数据上分界干净,无边界模糊样本:1.5 < 9.8 置灰;54.9 > 9.8 保留。 +恰好切出可编辑带 < 10 米的那 6 段。 + +判断必须在上限生效**之后**用实际窗口算,而不是拿未截断的 `cutback / length` 之和—— +后者与「过渡段放不下」这件事没有直接关系。 + +## 验收标准 + +- [ ] 可编辑带短于道路宽度的 segment,其全部 `road-*` 手柄 `editable: false` 且带 `disabledReason`。 +- [ ] `disabledReason` 复用既有文案,引导进入 JunctionTools。 +- [ ] 可编辑带不短于道路宽度的 segment,行为与当前 main 完全一致(手柄数量、位置、`editable: true`)。 +- [ ] 置灰不改变 `reserves` 本身,也不改变任何几何输出:`compileGeometry` 结果与置灰前逐字节一致。 +- [ ] 置灰的 segment 仍然出现在 manifest 中(不是被删除),否则客户端无法解释为什么不能编辑。 +- [ ] junction 手柄不受影响,仍然 `editable: true`。 +- [ ] `test/direct-edit-solver.js` 覆盖:短路段全部置灰、正常路段不受影响、判据边界(带长 ≈ 宽度)。 +- [ ] `npm run test`、`npm run test:client`、`npm run test:client:unit`、`npm run format:check`、`npm run build` 全绿。 +- [ ] 客户端无需改动即可显示灰色手柄与原因(`08-26-direct-edit-map-editor` 已实现该路径)。 + +## 不做 + +- 不改 `junctionReserves()` 的 0.45 上限本身。上限保护的是 reserve 语义,改它会牵动 junction 侧几何。 +- 不改 cutback 的推导公式。 +- 不实现 JunctionTools 侧的编辑能力(属 `08-26-direct-edit-junction-tools`)。 +- 不做客户端改动。 +- 不把置灰的 segment 从 manifest 里移除。 + +## 顺序依赖 + +与 `08-26-direct-edit-map-editor` 的第 4、5 步无依赖,可并行或后置。 +客户端置灰渲染路径已在该任务第 3 步交付并有单测覆盖,本任务只补上服务端的输入。 diff --git a/.trellis/tasks/08-27-junction-dominated-roads/task.json b/.trellis/tasks/08-27-junction-dominated-roads/task.json new file mode 100644 index 0000000..0fcbe8f --- /dev/null +++ b/.trellis/tasks/08-27-junction-dominated-roads/task.json @@ -0,0 +1,26 @@ +{ + "id": "junction-dominated-roads", + "name": "junction-dominated-roads", + "title": "路口主导的短路段整条置灰", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-27", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-26-direct-manipulation-road-editor", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/src/compile/direct-edit-solver.js b/src/compile/direct-edit-solver.js index 27fd504..5d07f67 100644 --- a/src/compile/direct-edit-solver.js +++ b/src/compile/direct-edit-solver.js @@ -482,11 +482,25 @@ function makeRoadHandles(model, groups, reserves, constraints) { const unavailable = start >= end; const width = roads.reduce((sum, item) => sum + (Number(item.widthMeters) || 0), 0); const laneCount = roads.reduce((sum, item) => sum + (Number(item.laneCount) || 0), 0); + // `junctionReserves()` caps each end's reserve at 0.45, so `unavailable` + // alone can never fire — it would need start >= end, i.e. 0.45 >= 0.55. On a + // road the junctions geometrically fill, that cap invents an editable band in + // the middle (1.5 m on a 14.5 m road) with no room for the smoothstep + // transitions a road-interval constraint needs at both ends. + // + // Compare in meters rather than stations: a transition needs real distance, + // and a band shorter than the road is wide cannot carry a cross-section + // change. This only marks handles read-only; existing constraints on such a + // road keep being solved, so a compiler upgrade never silently drops a saved + // edit and the geometry output is unchanged. + const bandMeters = Math.max(0, end - start) * length; + const junctionDominated = bandMeters < width; const point = coordinateAt(road.centerline, station); const tangent = tangentAzimuth(road.centerline, station); const normal = (tangent + 90) % 360; const interval = { type: 'road-interval', roadId: road.id, startStation: start, endStation: end }; - const baseDisabled = unavailable ? '该道路全部位于路口保留区,请进入 JunctionTools 编辑。' : undefined; + const baseDisabled = + unavailable || junctionDominated ? '该道路全部位于路口保留区,请进入 JunctionTools 编辑。' : undefined; const add = (kind, anchor, value, min, max, axis, position, affects, constraint) => { const disabledReason = baseDisabled; handles.push({ @@ -503,7 +517,12 @@ function makeRoadHandles(model, groups, reserves, constraints) { }); }; for (const side of ['left', 'right']) { - const sideSign = side === 'left' ? 1 : -1; + // Left is `tangent - 90`, matching the geometry compiler: `offsetLine()` + // shifts a positive offset counter-clockwise from the direction of travel, + // and sidewalks use `heading + (side === 'left' ? -90 : 90)`. Placing the + // left handle at `tangent + 90` drew it over the right kerb, so dragging the + // visually-left handle moved the right edge. + const sideSign = side === 'left' ? -1 : 1; const edgeAnchor = { ...interval, side }; const edgeConstraint = constraintFor(constraints, 'road-edge-offset', edgeAnchor); add( diff --git a/test/direct-edit-solver.js b/test/direct-edit-solver.js index 3b055f9..b7ccdb1 100644 --- a/test/direct-edit-solver.js +++ b/test/direct-edit-solver.js @@ -86,6 +86,27 @@ assert.ok(junctionResolution.handles.handles.every((handle) => handle.position.e assert.ok(junctionResolution.handles.handles.every((handle) => Number.isFinite(handle.axisAzimuth))); assert.ok(junctionResolution.handles.handles.some((handle) => handle.affects.includes('junction:node/junction'))); +// The left handle must sit on the geometry compiler's left. `offsetLine()` shifts a +// positive offset counter-clockwise from the direction of travel and sidewalks use +// `heading + (side === 'left' ? -90 : 90)`, so for the north-heading road:a that is +// west. Drawing it east put it over the right kerb, and dragging the visually-left +// handle then moved the right edge. +const northEdges = junctionResolution.handles.handles.filter( + (handle) => handle.kind === 'road-edge-offset' && handle.anchor.roadId === 'road:a', +); +const leftEdge = northEdges.find((handle) => handle.anchor.side === 'left'); +const rightEdge = northEdges.find((handle) => handle.anchor.side === 'right'); +assert.ok(leftEdge && rightEdge, 'both edge handles must be published'); +assert.ok(leftEdge.position[0] < 113, 'left edge handle must sit west of a north-heading centerline'); +assert.ok(rightEdge.position[0] > 113, 'right edge handle must sit east of a north-heading centerline'); +const northSidewalks = junctionResolution.handles.handles.filter( + (handle) => handle.kind === 'road-sidewalk-width' && handle.anchor.roadId === 'road:a', +); +assert.ok( + northSidewalks.find((handle) => handle.anchor.side === 'left').position[0] < 113, + 'the sidewalk handle must follow the same side convention as the edge handle', +); + const anchored = constraint({ id: 'edge-on-road-a', anchor: { type: 'road-interval', roadId: 'road:a', startStation: 0.2, endStation: 0.8, side: 'left' }, @@ -228,6 +249,109 @@ assert.deepEqual( ['a', 'b', 'c'], ); +// A short road sandwiched between two junctions. `junctionReserves()` caps each +// end's reserve at 0.45, so `start >= end` can never fire; what decides whether +// an edit is offered is the surviving band measured in meters against the road's +// own width. 0.00013 degrees of latitude is about 14.4 m, shorter than the +// cutback the approaches demand at either end, so both ends hit the cap and the +// band lands at ~1.4 m. +function sandwichModel(shortWidthMeters, shortLaneCount) { + const nodeA = [113, 30]; + const nodeB = [113, 30.00013]; + const road = (id, from, to, widthMeters, laneCount) => ({ + id: `road:${id}`, + segmentId: `segment:${id}`, + direction: 'forward', + centerline: [from, to], + sourceNodeIds: [`node-${id}-a`, `node-${id}-b`], + osmWayIds: [id], + widthMeters, + laneCount, + sidewalkLeft: true, + sidewalkRight: true, + }); + const endpoint = (roadId, side, nodeId, coordinate) => ({ + id: `endpoint:${roadId}:${side}`, + roadId, + side, + nodeId, + coordinate, + }); + return { + roads: [ + road('short', nodeA, nodeB, shortWidthMeters, shortLaneCount), + road('a1', nodeA, [112.999, 30], 6, 2), + road('a2', nodeA, [113.001, 30], 6, 2), + road('b1', nodeB, [112.999, 30.00013], 6, 2), + road('b2', nodeB, [113.001, 30.00013], 6, 2), + ], + endpoints: [ + endpoint('road:short', 'start', 'node-a', nodeA), + endpoint('road:a1', 'start', 'node-a', nodeA), + endpoint('road:a2', 'start', 'node-a', nodeA), + endpoint('road:short', 'end', 'node-b', nodeB), + endpoint('road:b1', 'start', 'node-b', nodeB), + endpoint('road:b2', 'start', 'node-b', nodeB), + ], + connections: [], + }; +} + +function roadHandlesFor(resolution, roadId) { + return resolution.handles.handles.filter( + (handle) => handle.kind.startsWith('road-') && handle.anchor.roadId === roadId, + ); +} + +// The short road's band is ~1.4 m against a 10 m width, so every road handle on +// it is read-only and says why. The long approach at the same junction keeps its +// handles, which is what proves the rule is about the band and not about being +// adjacent to a junction. +const sandwich = resolveDirectEditConstraints(sandwichModel(10, 4), document([]), { revisionId: 'rev-sandwich' }); +const shortHandles = roadHandlesFor(sandwich, 'road:short'); +const longHandles = roadHandlesFor(sandwich, 'road:a1'); +assert.ok(shortHandles.length > 0, 'the blocked road must still publish handles so the map can explain them'); +assert.ok(longHandles.length > 0); +assert.ok(shortHandles.every((handle) => handle.editable === false)); +assert.ok(shortHandles.every((handle) => handle.disabledReason.includes('JunctionTools'))); +assert.ok(longHandles.every((handle) => handle.editable === true)); +assert.ok(longHandles.every((handle) => handle.disabledReason === undefined)); + +// Junction kinds are JunctionTools' territory and must stay draggable there. +assert.ok( + sandwich.handles.handles + .filter((handle) => handle.kind.startsWith('junction-')) + .every((handle) => handle.editable === true), +); + +// The band is compared in meters against the road's own width. The 0.45 cap keeps +// the band at ~1.4 m either way, so changing only the width crosses the boundary. +assert.ok( + roadHandlesFor(resolveDirectEditConstraints(sandwichModel(2, 2), document([])), 'road:short').every( + (h) => !h.editable, + ), +); +assert.ok( + roadHandlesFor(resolveDirectEditConstraints(sandwichModel(1, 2), document([])), 'road:short').every( + (h) => h.editable, + ), +); + +// Greying is a UI affordance, not a retroactive veto: an edit already saved +// against this road keeps being solved, or a compiler upgrade would silently drop +// it and the geometry would move underneath the user. +const onBlocked = resolveDirectEditConstraints( + sandwichModel(10, 4), + document([ + constraint({ + id: 'edge-on-short', + anchor: { type: 'road-interval', roadId: 'road:short', startStation: 0.45, endStation: 0.55, side: 'left' }, + }), + ]), +); +assert.equal(onBlocked.constraintStates[0].applied, true); +assert.equal(onBlocked.roadProfiles.get('road:short').edgeOffsets.left, 1.5); + // The solver must be free of file and network access so preview can share it. const source = require('fs').readFileSync(require.resolve('../src/compile/direct-edit-solver'), 'utf8'); for (const forbidden of ["require('fs')", "require('path')", "require('http')", "require('https')"]) diff --git a/workbench/client/src/App.tsx b/workbench/client/src/App.tsx index 3105706..4f606e6 100644 --- a/workbench/client/src/App.tsx +++ b/workbench/client/src/App.tsx @@ -1,12 +1,23 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Download, RefreshCw, Save, Upload } from 'lucide-react'; import { api } from './lib/api'; import { MapCanvas } from './components/MapCanvas'; -import { directEditEnabled } from './edit/flag'; -import { disabledReasonOf, handlesForSegment } from './edit/selection'; -import type { HandleManifest } from './edit/types'; +import { directEditEnabled, intervalEditingSupported } from './edit/flag'; +import { PreviewRequester, type PreviewDraft } from './edit/preview-request'; +import { anchorSnapshotFor, constraintValueFor, draftConstraint, operationFor } from './edit/projection'; +import { disabledReasonOf, handlesForSegment, intervalRangeHandles, type IntervalRangeHandle } from './edit/selection'; +import { EditSession } from './edit/session'; +import type { + EditDiagnostic, + EditHandle, + HandleManifest, + PreviewLayerName, + RoadConstraint, + RoadEditOperation, + RoadIntervalAnchor, +} from './edit/types'; import { Button } from './ui/button'; -import type { GeoFeature, Override, Road, WorkbenchState } from './types/state'; +import type { GeoFeature, GeoJson, Override, Road, WorkbenchState } from './types/state'; import type { LayerName } from './map/layers'; const layerLabels: Array<[LayerName, string]> = [ @@ -99,12 +110,21 @@ function App() { api .editState() .then((value) => { - if ('handles' in value) setManifest(value.handles); + if (!('handles' in value)) return; + setManifest(value.handles); + // A drag drafts on top of whatever is already saved, so keep the active + // document's constraints rather than sending a lone constraint. + savedConstraints.current = value.document.constraints; + savedOperations.current = value.document.operations; }) // A manifest failure must not take the workbench down with it; the map just // shows no handles. .catch((error: Error) => setStatus(`直接编辑手柄不可用:${error.message}`)); - }, []); + // Keyed on `state`, not mounted once: on a fresh import the first attempt runs + // before any area exists and comes back inactive, so the handles never appeared + // until a manual reload. A recompile also moves the geometry the manifest + // describes, so the manifest has to be refetched with it. + }, [state]); const editHandles = useMemo(() => { if (!directEditEnabled || !state) return []; // Handle anchors carry a directional road id while reserves are keyed by @@ -116,6 +136,96 @@ function App() { // boundary, so the first blocked handle explains itself in the header. const blockedHandle = editHandles.find((handle) => !handle.editable); const blockedReason = blockedHandle ? disabledReasonOf(blockedHandle) : undefined; + // Every road handle on a segment shares one interval — `makeRoadHandles()` builds + // it once per segment — so one pair of range ends covers the whole selection. + const editRanges = useMemo(() => { + // Hidden until compileGeometry() honours profile.interval; see flag.ts. + if (!directEditEnabled || !intervalEditingSupported || !state || !selected || !manifest) return []; + const parent = editHandles.find((handle) => handle.editable && handle.anchor.type === 'road-interval'); + const roadId = parent && 'roadId' in parent.anchor ? parent.anchor.roadId : null; + const road = roadId ? state.compiled.model.roads.find((item) => item.id === roadId) : undefined; + if (!parent || !road) return []; + return intervalRangeHandles(parent, road.centerline, manifest.reserves, selected.segmentId); + }, [editHandles, manifest, selected, state]); + const [preview, setPreview] = useState> | null>(null); + const [editDiagnostics, setEditDiagnostics] = useState([]); + // The working set: the saved document plus whatever this session has drafted. + // Advanced on pointerup, not on every move, so an abandoned drag leaves nothing + // behind — and so a later range drag can re-anchor the edit a value drag made. + const savedConstraints = useRef([]); + /** Their operations. A constraint without its operation is rejected as a 400. */ + const savedOperations = useRef([]); + const sessionRef = useRef(null); + if (directEditEnabled && !sessionRef.current) sessionRef.current = new EditSession(); + const requester = useMemo(() => { + const session = sessionRef.current; + if (!session) return null; + return new PreviewRequester({ + session, + send: (request, signal) => api.editPreview(request, signal), + onSettled: (outcome, response) => { + setEditDiagnostics(response.diagnostics); + // The response carries a re-solved manifest. Keeping the stale one made the + // handles snap back to their pre-drag positions the moment the ghost cleared. + setManifest(response.handles); + // A rejected draft leaves the last valid preview on screen; the session + // has already decided that, so this only mirrors its verdict. + if (!outcome.blocked) setPreview(response.layers); + }, + onError: (error) => setStatus(`预览失败:${(error as Error).message}`), + }); + }, []); + /** A drag becomes one drafted constraint layered over the saved document. */ + const draftFor = (handle: EditHandle, value: number): PreviewDraft | null => { + const roadId = 'roadId' in handle.anchor ? handle.anchor.roadId : null; + const road = roadId ? state?.compiled.model.roads.find((item) => item.id === roadId) : undefined; + // Without a centerline there is no snapshot to record, so drop the drag rather + // than send a constraint the server would have to reject. + if (!road || road.centerline.length < 2) return null; + const constraint = draftConstraint( + handle, + handle.anchor, + constraintValueFor(handle, value), + anchorSnapshotFor(handle, road.centerline, road.sourceNodeIds), + { + constraintId: `constraint:${handle.handleId}`, + operationId: `operation:${handle.handleId}`, + createdAt: new Date().toISOString(), + }, + ); + const operation = operationFor(constraint); + // Both halves travel: validateEditDocument() rejects a constraint whose + // provenance.operationId is not a recorded operation, and that check covers the + // already-saved constraints too, not just the one being dragged. + return { + constraints: [...savedConstraints.current.filter((item) => item.id !== constraint.id), constraint], + operations: [...savedOperations.current.filter((item) => item.id !== operation.id), operation], + }; + }; + /** + * A range drag re-anchors the constraints already on this road rather than + * creating one: the interval is a property of the road's edit, shared by every + * constraint on it. With no constraint yet there is nothing to re-anchor. + */ + const rangeDraftFor = (range: IntervalRangeHandle, anchor: RoadIntervalAnchor): PreviewDraft | null => { + const onRoad = (item: RoadConstraint) => + item.anchor.type === 'road-interval' && item.anchor.roadId === range.roadId; + if (!savedConstraints.current.some(onRoad)) return null; + return { + constraints: savedConstraints.current.map((item) => + onRoad(item) + ? { ...item, anchor: { ...item.anchor, startStation: anchor.startStation, endStation: anchor.endStation } } + : item, + ), + operations: savedOperations.current, + }; + }; + /** Both drag kinds commit their result here so the next drag builds on it. */ + const advance = (draft: PreviewDraft | null) => { + if (!draft) return; + savedConstraints.current = draft.constraints ?? savedConstraints.current; + savedOperations.current = draft.operations ?? savedOperations.current; + }; const stage = (change: Override) => setStaged((current) => [...current.filter((item) => item.id !== change.id), change]); const save = async () => { @@ -180,12 +290,13 @@ function App() { {staged.length ? `未保存修改 ${staged.length} 项` : '所有修改已保存'} {directEditEnabled ? ( - - {editHandles.length - ? `手柄 ${editHandles.length} 个` - : selected - ? '当前道路无可编辑手柄' - : '选中道路以显示手柄'} + item.severity === 'error') ? 'dirty' : ''}> + {editDiagnostics.find((item) => item.severity === 'error')?.message || + (editHandles.length + ? `手柄 ${editHandles.length} 个` + : selected + ? '当前道路无可编辑手柄' + : '选中道路以显示手柄')} {blockedReason ? `|${blockedReason}` : ''} ) : null} @@ -251,6 +362,32 @@ function App() { onSelectRoad={setSelected} onFeature={handleFeature} handles={editHandles} + ranges={editRanges} + preview={preview} + onHandleDrag={(handle, value) => { + const draft = draftFor(handle, value); + if (draft) requester?.schedule(draft); + }} + onHandleDragEnd={(handle, value) => { + const draft = draftFor(handle, value); + if (!draft) return; + requester?.flush(draft); + advance(draft); + }} + onHandleBlocked={(_handle, reason) => setStatus(reason)} + onRangeDrag={(range, anchor) => { + const draft = rangeDraftFor(range, anchor); + if (draft) requester?.schedule(draft); + }} + onRangeEnd={(range, anchor) => { + const draft = rangeDraftFor(range, anchor); + if (!draft) { + setStatus('先拖动路缘、步行带或车道分隔手柄产生一次编辑,范围手柄才有可调整的区间。'); + return; + } + requester?.flush(draft); + advance(draft); + }} /> ) => void; /** Already filtered to the selected road. Ignored unless `directEdit` is on. */ handles?: EditHandle[]; + /** The two ends of the affected interval, drawn as squares. */ + ranges?: IntervalRangeHandle[]; + /** Authoritative preview geometry; null returns the map to the baseline. */ + preview?: Partial> | null; + /** Live value during a drag — the caller debounces the preview request. */ + onHandleDrag?: (handle: EditHandle, value: number) => void; + /** Final value on pointerup — the caller sends it without debouncing. */ + onHandleDragEnd?: (handle: EditHandle, value: number) => void; + onHandleBlocked?: (handle: EditHandle, reason: string) => void; + /** Live interval while a range end is dragged. */ + onRangeDrag?: (range: IntervalRangeHandle, anchor: RoadIntervalAnchor) => void; + onRangeEnd?: (range: IntervalRangeHandle, anchor: RoadIntervalAnchor) => void; } -export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFeature, handles }: Props) { +export function MapCanvas({ + state, + selected, + visible, + scene, + onSelectRoad, + onFeature, + handles, + ranges, + preview, + onHandleDrag, + onHandleDragEnd, + onHandleBlocked, + onRangeDrag, + onRangeEnd, +}: Props) { const target = useRef(null); const mapRef = useRef(null); const layersRef = useRef | null>(null); const handleLayerRef = useRef(null); + const ghostRef = useRef(null); + const previewRef = useRef(null); + const visibleRef = useRef(visible); + visibleRef.current = visible; + // Callbacks live in refs so the map is built once and never rebuilt when a + // parent re-renders with new closures. + const dragRef = useRef({ onHandleDrag, onHandleDragEnd, onHandleBlocked, onRangeDrag, onRangeEnd }); + dragRef.current = { onHandleDrag, onHandleDragEnd, onHandleBlocked, onRangeDrag, onRangeEnd }; const selectedRef = useRef(selected); const stateRef = useRef(state); const sceneRef = useRef(scene); @@ -40,10 +79,24 @@ export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFea // With `directEdit` off nothing below exists: no extra source, no layer, no // interaction — the canvas is byte-for-byte the shipped behaviour. const handleLayer = directEditEnabled ? new EditHandleLayer() : null; + const ghost = directEditEnabled ? new EditGhostLayer() : null; + const previewLayer = directEditEnabled + ? new EditPreviewLayer( + () => selectedRef.current, + () => sceneRef.current, + ) + : null; handleLayerRef.current = handleLayer; + ghostRef.current = ghost; + previewRef.current = previewLayer; const map = new Map({ target: target.current, - layers: handleLayer ? [...Object.values(layers), handleLayer.layer] : Object.values(layers), + layers: [ + ...Object.values(layers), + ...(previewLayer ? previewLayer.all() : []), + ...(handleLayer ? [handleLayer.layer] : []), + ...(ghost ? [ghost.layer] : []), + ], view: new View({ center: [0, 0], zoom: 2 }), }); mapRef.current = map; @@ -56,6 +109,21 @@ export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFea style: null, }); map.addInteraction(select); + if (handleLayer && ghost) { + map.addInteraction( + createHandleDragInteraction({ + layer: handleLayer.layer, + ghost, + handleAt: (handleId) => handleLayer.handle(handleId), + rangeAt: (handleId) => handleLayer.range(handleId), + onDrag: (handle, value) => dragRef.current.onHandleDrag?.(handle, value), + onEnd: (handle, value) => dragRef.current.onHandleDragEnd?.(handle, value), + onRangeDrag: (range, anchor) => dragRef.current.onRangeDrag?.(range, anchor), + onRangeEnd: (range, anchor) => dragRef.current.onRangeEnd?.(range, anchor), + onBlocked: (handle, reason) => dragRef.current.onHandleBlocked?.(handle, reason), + }), + ); + } const listener = ({ selected: values }: { selected: import('ol/Feature').default[] }) => { const properties = values[0]?.getProperties(); if (!properties) return; @@ -94,8 +162,17 @@ export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFea useEffect(() => { // Null when the flag is off, so this whole path is inert on main. // Only the editHandles source is replaced; baseline layers are never touched. - handleLayerRef.current?.render(handles ?? []); - }, [handles]); + handleLayerRef.current?.render(handles ?? [], ranges ?? []); + }, [handles, ranges]); + useEffect(() => { + const previewLayer = previewRef.current; + const baseline = layersRef.current; + if (!previewLayer || !baseline) return; + // Showing hides the baseline layers it supersedes; clearing gives them back + // the visibility the layer switches ask for, never a hardcoded default. + if (preview) previewLayer.show(preview, baseline); + else previewLayer.clear(baseline, visibleRef.current); + }, [preview]); useEffect(() => { const layers = layersRef.current; if (!layers) return; diff --git a/workbench/client/src/edit/drag-interaction.ts b/workbench/client/src/edit/drag-interaction.ts new file mode 100644 index 0000000..ac2cf85 --- /dev/null +++ b/workbench/client/src/edit/drag-interaction.ts @@ -0,0 +1,133 @@ +// Dragging a handle, on native OpenLayers. +// +// Two things the ol-ext probe settled are baked in here: +// +// 1. The handle position is derived from the clamped constraint value, never from +// the raw pointer delta. `Transform` translated its proxy by the delta, so a +// drag reading -24.1 m left the handle 24 m out while the constraint clamped at +// -5.4 m. `EditGhostLayer.show()` takes the value, not the cursor. +// 2. Hit-testing happens once, on pointerdown. ol-ext ran `forEachFeatureAtPixel` +// from `handleMoveEvent_`, i.e. a canvas readback every mousemove. A drag only +// needs to know what it grabbed at the start. +// +// Two kinds of handle share the gesture: a value handle moves the constraint's +// number, a range end moves the interval its anchor covers. They are branched here +// rather than in two interactions so only one of them can ever own a pointer. +// +// Baseline layers are never touched: this reads the handle layer and writes only +// to the ghost source. + +import PointerInteraction from 'ol/interaction/Pointer'; +import type MapBrowserEvent from 'ol/MapBrowserEvent'; +import type VectorLayer from 'ol/layer/Vector'; +import type VectorSource from 'ol/source/Vector'; +import type { EditGhostLayer } from './ghost-layer'; +import { HANDLE_ID } from './handle-layer'; +import type { Coordinate } from './meters'; +import { projectHandleValue, projectIntervalEnd } from './projection'; +import type { IntervalRangeHandle } from './selection'; +import type { EditHandle, RoadIntervalAnchor } from './types'; + +/** + * OpenLayers hands every pointer handler the same widened event: the union covers + * keyboard and wheel because `PointerInteraction` shares one dispatch path. The + * fields used here — `map`, `pixel`, `coordinate` — exist on all of them. + */ +type MapPointerEvent = MapBrowserEvent; + +type Active = + | { kind: 'value'; handle: EditHandle; origin: Coordinate } + | { kind: 'range'; range: IntervalRangeHandle; origin: Coordinate }; + +export interface HandleDragOptions { + /** The `editHandles` layer, the only layer hit-tested. */ + layer: VectorLayer; + ghost: EditGhostLayer; + /** Manifest lookup by handle id; handles carry nothing but their id. */ + handleAt: (handleId: string) => EditHandle | undefined; + /** Range-end lookup, for the anchor-moving handles. */ + rangeAt: (handleId: string) => IntervalRangeHandle | undefined; + /** Live value during a value drag. Callers debounce the preview request. */ + onDrag: (handle: EditHandle, value: number) => void; + /** Final value on pointerup. Callers send this one without debouncing. */ + onEnd: (handle: EditHandle, value: number) => void; + /** Live interval during a range drag. */ + onRangeDrag: (range: IntervalRangeHandle, anchor: RoadIntervalAnchor) => void; + onRangeEnd: (range: IntervalRangeHandle, anchor: RoadIntervalAnchor) => void; + /** A reserve handle refused the drag, with the manifest's reason. */ + onBlocked?: (handle: EditHandle, reason: string) => void; +} + +export function createHandleDragInteraction(options: HandleDragOptions): PointerInteraction { + let active: Active | null = null; + + const intervalAt = (state: Extract, to: Coordinate) => + projectIntervalEnd( + state.range.anchor, + state.range.end, + state.origin, + to, + state.range.tangentAzimuth, + state.range.roadLengthMeters, + state.range.window, + ); + + return new PointerInteraction({ + handleDownEvent: (event: MapPointerEvent) => { + const feature = event.map.forEachFeatureAtPixel(event.pixel, (candidate) => candidate, { + layerFilter: (layer) => layer === options.layer, + hitTolerance: 10, + }); + if (!feature) return false; + const id = String(feature.get(HANDLE_ID)); + const origin = event.coordinate as Coordinate; + + const range = options.rangeAt(id); + if (range) { + active = { kind: 'range', range, origin }; + options.ghost.showInterval(range, range.anchor); + return true; + } + + const handle = options.handleAt(id); + if (!handle) return false; + if (!handle.editable) { + // Explain, then decline the gesture so the map still pans. Swallowing it + // would make a reserve handle feel broken rather than owned elsewhere. + options.onBlocked?.(handle, handle.disabledReason || '该手柄不可拖动。'); + return false; + } + active = { kind: 'value', handle, origin }; + options.ghost.show(handle, handle.value.current); + return true; + }, + + handleDragEvent: (event: MapPointerEvent) => { + const state = active; + if (!state) return; + const to = event.coordinate as Coordinate; + if (state.kind === 'range') { + const anchor = intervalAt(state, to); + options.ghost.showInterval(state.range, anchor); + options.onRangeDrag(state.range, anchor); + return; + } + const value = projectHandleValue(state.handle, state.origin, to); + options.ghost.show(state.handle, value); + options.onDrag(state.handle, value); + }, + + handleUpEvent: (event: MapPointerEvent) => { + const state = active; + active = null; + if (!state) return false; + const to = event.coordinate as Coordinate; + // The final request is not debounced: whatever the pointer settled on is the + // value the user meant, and it must not be dropped by a pending timer. + if (state.kind === 'range') options.onRangeEnd(state.range, intervalAt(state, to)); + else options.onEnd(state.handle, projectHandleValue(state.handle, state.origin, to)); + options.ghost.clear(); + return false; + }, + }); +} diff --git a/workbench/client/src/edit/flag.ts b/workbench/client/src/edit/flag.ts index 975e17b..211e101 100644 --- a/workbench/client/src/edit/flag.ts +++ b/workbench/client/src/edit/flag.ts @@ -29,3 +29,18 @@ function readFlag(): boolean { * halfway through and build a half-wired map. */ export const directEditEnabled = readFlag(); + +/** + * Whether a `road-interval` constraint actually applies to its interval. + * + * It does not yet. The solver builds `profile.interval` and `profile.transitions` + * (direct-edit-solver.js:274, :418) but `compileGeometry()` reads neither — grep + * `native-road.js` for `.interval` and it comes back empty. Every direct edit is + * therefore applied to the whole road. + * + * So the range handles are hidden: `intervalRangeHandles()` and + * `projectIntervalEnd()` are correct and unit-tested, but a control whose drag + * changes nothing is worse than no control. Flip this to `true` in the same commit + * that makes the geometry stage honour the interval. + */ +export const intervalEditingSupported = false; diff --git a/workbench/client/src/edit/ghost-layer.ts b/workbench/client/src/edit/ghost-layer.ts new file mode 100644 index 0000000..5cf0315 --- /dev/null +++ b/workbench/client/src/edit/ghost-layer.ts @@ -0,0 +1,125 @@ +// The `editGhost` source: client-side immediate feedback while a drag is live. +// +// Written to directly, never through React state. The ol-ext probe drove its +// readout from a setState per `translating` event and produced ~1600 renders in +// one session; React is the wrong owner of per-frame feedback. The ghost is also +// not authoritative — it shows where the handle now sits and how far it moved, +// while `editPreview` carries the geometry the server actually solved. + +import Feature from 'ol/Feature'; +import LineString from 'ol/geom/LineString'; +import Point from 'ol/geom/Point'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import CircleStyle from 'ol/style/Circle'; +import Fill from 'ol/style/Fill'; +import Stroke from 'ol/style/Stroke'; +import Style from 'ol/style/Style'; +import Text from 'ol/style/Text'; +import { coordinateAtStation, fromLonLat, type Coordinate } from './meters'; +import { handlePositionFor } from './projection'; +import type { IntervalRangeHandle } from './selection'; +import type { EditHandle, RoadIntervalAnchor } from './types'; + +const GUIDE = new Style({ + stroke: new Stroke({ color: '#00a5cf', width: 2, lineDash: [6, 4] }), +}); + +/** The stretch of road an interval edit applies to. */ +const BAND = new Style({ + stroke: new Stroke({ color: '#29695699', width: 14 }), +}); + +/** Drawn at the bound so it is obvious the handle stopped rather than stuck. */ +const CLAMPED = new Style({ + stroke: new Stroke({ color: '#d49318', width: 2, lineDash: [6, 4] }), +}); + +function knob(label: string, clamped: boolean): Style { + return new Style({ + image: new CircleStyle({ + radius: 8, + fill: new Fill({ color: clamped ? '#d49318' : '#00a5cf' }), + stroke: new Stroke({ color: '#fff', width: 2 }), + }), + text: new Text({ + text: label, + offsetY: -18, + font: '600 12px system-ui, sans-serif', + fill: new Fill({ color: '#1b2426' }), + stroke: new Stroke({ color: '#ffffffcc', width: 3 }), + }), + }); +} + +export class EditGhostLayer { + readonly layer: VectorLayer; + private readonly source = new VectorSource(); + + constructor() { + this.layer = new VectorLayer({ + source: this.source, + // Above the handles so the live knob is never occluded by the static one. + zIndex: 110, + style: (feature) => { + if (feature.get('band')) return BAND; + const label = feature.get('label'); + if (typeof label === 'string') return knob(label, Boolean(feature.get('clamped'))); + return feature.get('clamped') ? CLAMPED : GUIDE; + }, + }); + } + + /** + * Shows the handle at the position implied by `value`, plus a guide line back to + * where it started. `value` is already clamped by `projectHandleValue`, so a + * ghost pinned at the bound is the correct picture: the road cannot go further. + */ + show(handle: EditHandle, value: number): void { + const origin = fromLonLat(handle.position); + const moved = fromLonLat(handlePositionFor(handle, value)); + const clamped = value >= handle.value.max - 1e-6 || value <= handle.value.min + 1e-6; + const delta = value - handle.value.current; + this.source.clear(); + this.source.addFeatures([ + new Feature({ geometry: new LineString([origin, moved]), clamped }), + new Feature({ + geometry: new Point(moved), + label: `${delta >= 0 ? '+' : ''}${delta.toFixed(2)} 米`, + clamped, + }), + ]); + } + + /** + * Shows the interval a range drag is resizing: a band along the road plus its + * length. A pair of dots cannot convey *where* the edit applies, which is the + * whole point of the range handles. + */ + showInterval(range: IntervalRangeHandle, anchor: RoadIntervalAnchor): void { + const span = Math.max(0, anchor.endStation - anchor.startStation); + const steps = 24; + const points: Coordinate[] = []; + for (let index = 0; index <= steps; index += 1) { + const point = coordinateAtStation(range.centerline, anchor.startStation + (span * index) / steps); + if (point) points.push(point); + } + if (points.length < 2) return; + // Pinned when the dragged end has run into the reserve or the other end. + const station = range.end === 'start' ? anchor.startStation : anchor.endStation; + const clamped = station <= range.window.minStation + 1e-6 || station >= range.window.maxStation - 1e-6; + this.source.clear(); + this.source.addFeatures([ + new Feature({ geometry: new LineString(points.map((point) => fromLonLat(point))), band: true }), + new Feature({ + geometry: new Point(fromLonLat(points[range.end === 'start' ? 0 : points.length - 1])), + label: `${(span * range.roadLengthMeters).toFixed(1)} 米`, + clamped, + }), + ]); + } + + clear(): void { + this.source.clear(); + } +} diff --git a/workbench/client/src/edit/handle-layer.ts b/workbench/client/src/edit/handle-layer.ts index 6f44f04..3e0da7d 100644 --- a/workbench/client/src/edit/handle-layer.ts +++ b/workbench/client/src/edit/handle-layer.ts @@ -15,9 +15,10 @@ import VectorSource from 'ol/source/Vector'; import CircleStyle from 'ol/style/Circle'; import Fill from 'ol/style/Fill'; import Stroke from 'ol/style/Stroke'; +import RegularShape from 'ol/style/RegularShape'; import Style from 'ol/style/Style'; import { fromLonLat } from './meters'; -import { disabledReasonOf } from './selection'; +import { disabledReasonOf, type IntervalRangeHandle } from './selection'; import type { EditHandle } from './types'; /** The only property a handle feature carries. */ @@ -29,6 +30,20 @@ const EDITABLE_STYLE: Record = { 'road-lane-divider': handleStyle('#8f6fd0'), }; +/** + * Range ends are squares so they never read as another value knob: dragging one + * moves where the edit applies along the road, not how much it changes. + */ +const RANGE_STYLE = new Style({ + image: new RegularShape({ + points: 4, + radius: 6, + angle: Math.PI / 4, + fill: new Fill({ color: '#296956' }), + stroke: new Stroke({ color: '#fff', width: 2 }), + }), +}); + /** Reserve handles stay visible so the boundary is explainable, but read as inert. */ const DISABLED_STYLE = new Style({ image: new CircleStyle({ @@ -52,6 +67,7 @@ export class EditHandleLayer { readonly layer: VectorLayer; private readonly source = new VectorSource(); private index = new Map(); + private ranges = new Map(); constructor() { this.layer = new VectorLayer({ @@ -59,19 +75,28 @@ export class EditHandleLayer { // Above every baseline layer, so a handle is never hidden under a surface. zIndex: 100, style: (feature) => { - const handle = this.handle(String(feature.get(HANDLE_ID))); + const id = String(feature.get(HANDLE_ID)); + if (this.ranges.has(id)) return RANGE_STYLE; + const handle = this.index.get(id); if (!handle) return undefined; return handle.editable ? EDITABLE_STYLE[handle.kind] : DISABLED_STYLE; }, }); } - /** Replaces the rendered handles. Only this source is touched. */ - render(handles: EditHandle[]): void { + /** + * Replaces the rendered handles. Only this source is touched. + * + * Range ends live in the same source because design.md allots the map three + * edit sources, not four — they are still handles, just handles that move the + * anchor rather than the value. + */ + render(handles: EditHandle[], ranges: IntervalRangeHandle[] = []): void { this.index = new Map(handles.map((handle) => [handle.handleId, handle])); + this.ranges = new Map(ranges.map((range) => [range.handleId, range])); this.source.clear(); this.source.addFeatures( - handles.map( + [...handles, ...ranges].map( (handle) => new Feature({ geometry: new Point(fromLonLat(handle.position)), @@ -83,9 +108,15 @@ export class EditHandleLayer { clear(): void { this.index = new Map(); + this.ranges = new Map(); this.source.clear(); } + /** Range lookup, the anchor-moving counterpart of `handle()`. */ + range(handleId: string): IntervalRangeHandle | undefined { + return this.ranges.get(handleId); + } + /** Manifest lookup — the single path from a rendered feature back to semantics. */ handle(handleId: string): EditHandle | undefined { return this.index.get(handleId); diff --git a/workbench/client/src/edit/meters.test.ts b/workbench/client/src/edit/meters.test.ts index 9e90bf8..7e4c7f6 100644 --- a/workbench/client/src/edit/meters.test.ts +++ b/workbench/client/src/edit/meters.test.ts @@ -1,11 +1,14 @@ import { describe, expect, it } from 'vitest'; import { + azimuthBetween, + coordinateAtStation, fromLonLat, haversineMeters, mercatorUnitsForMeters, offsetCoordinate, polylineLengthMeters, signedMetersAlongAxis, + tangentAzimuthAt, toLonLat, type Coordinate, } from './meters'; @@ -92,6 +95,85 @@ describe('signed axis projection', () => { }); }); +describe('station interpolation', () => { + const straight: Coordinate[] = [ + [0, 0], + [0, 0.002], + ]; + const bent: Coordinate[] = [ + [0, 0], + [0, 0.001], + [0.001, 0.001], + ]; + + it('returns the ends at station 0 and 1', () => { + expect(coordinateAtStation(straight, 0)).toEqual([0, 0]); + expect(coordinateAtStation(straight, 1)).toEqual([0, 0.002]); + }); + + it('interpolates by arc length, not by vertex index', () => { + const middle = coordinateAtStation(straight, 0.5)!; + expect(middle[1]).toBeCloseTo(0.001, 7); + // On the bent line the halfway point lands at the corner, because both legs + // are the same length — index-based interpolation would land elsewhere. + const corner = coordinateAtStation(bent, 0.5)!; + expect(corner[0]).toBeCloseTo(0, 5); + expect(corner[1]).toBeCloseTo(0.001, 5); + }); + + it('clamps stations outside 0..1', () => { + expect(coordinateAtStation(straight, -1)).toEqual([0, 0]); + expect(coordinateAtStation(straight, 2)).toEqual([0, 0.002]); + }); + + it('handles degenerate input without throwing', () => { + expect(coordinateAtStation([], 0.5)).toBeNull(); + expect(coordinateAtStation([[1, 2]], 0.5)).toEqual([1, 2]); + expect( + coordinateAtStation( + [ + [1, 2], + [1, 2], + ], + 0.5, + ), + ).toEqual([1, 2]); + }); +}); + +describe('bearings', () => { + it('reads the cardinal directions', () => { + expect(azimuthBetween([0, 0], [0, 0.001])).toBeCloseTo(0, 4); + expect(azimuthBetween([0, 0], [0.001, 0])).toBeCloseTo(90, 4); + expect(azimuthBetween([0, 0.001], [0, 0])).toBeCloseTo(180, 4); + expect(azimuthBetween([0.001, 0], [0, 0])).toBeCloseTo(270, 4); + }); + + it('takes the tangent from the segment the station falls in', () => { + const bent: Coordinate[] = [ + [0, 0], + [0, 0.001], + [0.001, 0.001], + ]; + // First leg runs north, second runs east. + expect(tangentAzimuthAt(bent, 0.25)).toBeCloseTo(0, 3); + expect(tangentAzimuthAt(bent, 0.75)).toBeCloseTo(90, 3); + }); + + it('falls back to a usable value for degenerate lines', () => { + expect(tangentAzimuthAt([[0, 0]], 0.5)).toBe(0); + expect( + tangentAzimuthAt( + [ + [0, 0], + [0, 0], + ], + 0.5, + ), + ).toBeCloseTo(0, 6); + }); +}); + describe('spherical helpers', () => { it('matches a known great-circle distance', () => { expect(haversineMeters([0, 0], [0, 1])).toBeCloseTo(111195, 0); diff --git a/workbench/client/src/edit/meters.ts b/workbench/client/src/edit/meters.ts index 792e451..730d691 100644 --- a/workbench/client/src/edit/meters.ts +++ b/workbench/client/src/edit/meters.ts @@ -110,3 +110,55 @@ export function offsetCoordinate(point: Coordinate, azimuth: number, meters: num export function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } + +/** Initial bearing from `a` to `b`, degrees clockwise from true north. */ +export function azimuthBetween(a: Coordinate, b: Coordinate): number { + const lat1 = toRadians(a[1]); + const lat2 = toRadians(b[1]); + const dLon = toRadians(b[0] - a[0]); + const y = Math.sin(dLon) * Math.cos(lat2); + const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon); + return (toDegrees(Math.atan2(y, x)) + 360) % 360; +} + +/** + * Point at a normalized arc-length station along an EPSG:4326 polyline. + * + * Mirrors `coordinateAt()` in `src/compile/direct-edit-solver.js`. The client + * needs its own copy because the ghost has to draw the affected interval band + * along the road, which is a browser-only concern — the server's copy stays + * authoritative for the handle positions it publishes in the manifest. + */ +export function coordinateAtStation(line: Coordinate[], station: number): Coordinate | null { + if (line.length === 0) return null; + if (line.length === 1) return [line[0][0], line[0][1]]; + const total = polylineLengthMeters(line); + const last = line[line.length - 1]; + if (!(total > 0)) return [line[0][0], line[0][1]]; + let remaining = clamp(station, 0, 1) * total; + for (let index = 1; index < line.length; index += 1) { + const from = line[index - 1]; + const to = line[index]; + const length = haversineMeters(from, to); + if (length >= remaining) { + const ratio = length > 0 ? remaining / length : 0; + return [from[0] + (to[0] - from[0]) * ratio, from[1] + (to[1] - from[1]) * ratio]; + } + remaining -= length; + } + return [last[0], last[1]]; +} + +/** Direction the road runs at a station, for the range handles' drag axis. */ +export function tangentAzimuthAt(line: Coordinate[], station: number): number { + if (line.length < 2) return 0; + const total = polylineLengthMeters(line); + if (!(total > 0)) return azimuthBetween(line[0], line[line.length - 1]); + let remaining = clamp(station, 0, 1) * total; + for (let index = 1; index < line.length; index += 1) { + const length = haversineMeters(line[index - 1], line[index]); + if (length >= remaining) return azimuthBetween(line[index - 1], line[index]); + remaining -= length; + } + return azimuthBetween(line[line.length - 2], line[line.length - 1]); +} diff --git a/workbench/client/src/edit/preview-layer.ts b/workbench/client/src/edit/preview-layer.ts new file mode 100644 index 0000000..6e504e8 --- /dev/null +++ b/workbench/client/src/edit/preview-layer.ts @@ -0,0 +1,97 @@ +// The `editPreview` source set: authoritative geometry returned by the solver. +// +// Baseline layers stay read-only, which is the whole point of the split. So the +// preview gets its own layer set built by the same `createLayers()` factory — +// identical styling, no second copy of it — and while a preview is showing, the +// baseline layers it supersedes are hidden rather than overwritten. Clearing the +// preview restores them untouched. + +import GeoJSON from 'ol/format/GeoJSON'; +import { createLayers, type LayerName } from '../map/layers'; +import type { GeoJson, Road } from '../types/state'; +import type { PreviewLayerName } from './types'; + +const geojson = new GeoJSON(); + +/** + * Which baseline layer each preview collection stands in for. Mirrors the + * grouping `updateLayers()` uses, including the collections that share a layer. + */ +const TARGET: Record = { + roadSurface: 'native', + intersectionSurface: 'native', + sidewalkSurface: 'sidewalks', + laneCenterlines: 'lanes', + edgeLines: 'edgeLines', + laneSeparators: 'markings', + turnArrows: 'markings', + centerLines: 'centerLines', + directionArrows: 'directionArrows', + crosswalks: 'controls', + vehicleStopLines: 'controls', + connectors: 'connectors', +}; + +function read(value: GeoJson | null | undefined) { + return geojson.readFeatures((value || { type: 'FeatureCollection', features: [] }) as object, { + dataProjection: 'EPSG:4326', + featureProjection: 'EPSG:3857', + }); +} + +export class EditPreviewLayer { + readonly layers: ReturnType; + /** Baseline layers currently hidden because the preview supersedes them. */ + private hidden = new Set(); + + constructor(onRoad: () => Road | null, scene: () => boolean) { + this.layers = createLayers(onRoad, scene); + for (const layer of Object.values(this.layers)) { + layer.setVisible(false); + // Between the baseline and the handles. + layer.setZIndex(50); + } + } + + /** Every layer, for handing to the Map constructor once. */ + all() { + return Object.values(this.layers); + } + + /** + * Replaces only the collections the server actually returned. A collection the + * response omits leaves its baseline layer visible and untouched, which is what + * keeps a partial preview from blanking the rest of the map. + */ + show(preview: Partial>, baseline: ReturnType): void { + const touched = new Set(); + for (const name of Object.keys(preview) as PreviewLayerName[]) { + if (preview[name] === undefined) continue; + const target = TARGET[name]; + if (!target) continue; + const source = this.layers[target].getSource(); + if (!source) continue; + // Two collections can share a layer, so clear on first touch only. + if (!touched.has(target)) { + source.clear(); + touched.add(target); + } + source.addFeatures(read(preview[name])); + } + for (const name of touched) { + this.layers[name].setVisible(true); + baseline[name].setVisible(false); + this.hidden.add(name); + } + } + + /** Drops the preview and gives the baseline layers their visibility back. */ + clear(baseline: ReturnType, visible: Partial>): void { + for (const name of this.hidden) { + this.layers[name].getSource()?.clear(); + this.layers[name].setVisible(false); + baseline[name].setVisible(visible[name] !== false); + } + this.hidden.clear(); + } +} diff --git a/workbench/client/src/edit/preview-request.test.ts b/workbench/client/src/edit/preview-request.test.ts new file mode 100644 index 0000000..3a2a4f3 --- /dev/null +++ b/workbench/client/src/edit/preview-request.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { PREVIEW_DEBOUNCE_MS, PreviewRequester, type PreviewDraft, type PreviewTransport } from './preview-request'; +import { EditSession, type PreviewOutcome } from './session'; +import type { EditDiagnostic, EditPreviewResponse } from './types'; + +type SettledHandler = (outcome: PreviewOutcome, response: EditPreviewResponse) => void; +type ErrorHandler = (error: unknown) => void; + +function preview(previewSeq: number, diagnostics: EditDiagnostic[] = []): EditPreviewResponse { + return { + ok: true, + previewSeq, + degraded: false, + revisionId: 'rev-0001', + documentVersion: 0, + constraintStates: [], + diagnostics, + handles: { schema: 'road-edit-handles/v1', revisionId: 'rev-0001', previewSeq, handles: [], reserves: [] }, + layers: {}, + }; +} + +/** A transport whose responses are released by hand, to force out-of-order replies. */ +function deferredTransport() { + const pending: Array<{ seq: number; resolve: (value: EditPreviewResponse) => void }> = []; + const signals: AbortSignal[] = []; + const send: PreviewTransport = (request, signal) => { + signals.push(signal); + return new Promise((resolve) => pending.push({ seq: request.previewSeq, resolve })); + }; + return { send, pending, signals }; +} + +let session: EditSession; +let onSettled: Mock; +let onError: Mock; + +beforeEach(() => { + vi.useFakeTimers(); + session = new EditSession([]); + onSettled = vi.fn(); + onError = vi.fn(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('debounce', () => { + it('coalesces a burst of drag updates into one request', async () => { + const send = vi.fn(async (request) => preview(request.previewSeq)); + const requester = new PreviewRequester({ session, send, onSettled }); + requester.schedule({ constraints: [] }); + requester.schedule({ constraints: [] }); + requester.schedule({ constraints: [] }); + expect(send).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(PREVIEW_DEBOUNCE_MS); + expect(send).toHaveBeenCalledTimes(1); + expect(onSettled).toHaveBeenCalledTimes(1); + }); + + it('does not fire before the window elapses', async () => { + const send = vi.fn(async (request) => preview(request.previewSeq)); + new PreviewRequester({ session, send, onSettled }).schedule({ constraints: [] }); + await vi.advanceTimersByTimeAsync(PREVIEW_DEBOUNCE_MS - 1); + expect(send).not.toHaveBeenCalled(); + }); +}); + +describe('pointerup flush', () => { + it('sends immediately and drops the pending debounce', async () => { + const send = vi.fn(async (request) => preview(request.previewSeq)); + const requester = new PreviewRequester({ session, send, onSettled }); + requester.schedule({ constraints: [] }); + requester.flush({ constraints: [] }); + // The value the pointer settled on must not wait on, or be swallowed by, a timer. + expect(send).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(500); + expect(send).toHaveBeenCalledTimes(1); + }); +}); + +describe('cancellation', () => { + it('aborts the older request when a newer one starts', () => { + const { send, signals } = deferredTransport(); + const requester = new PreviewRequester({ session, send, onSettled }); + requester.flush({ constraints: [] }); + requester.flush({ constraints: [] }); + expect(signals[0].aborted).toBe(true); + expect(signals[1].aborted).toBe(false); + }); + + it('cancel() drops the timer and aborts in flight', async () => { + const { send, signals } = deferredTransport(); + const requester = new PreviewRequester({ session, send, onSettled }); + requester.flush({ constraints: [] }); + requester.schedule({ constraints: [] }); + requester.cancel(); + expect(signals[0].aborted).toBe(true); + await vi.advanceTimersByTimeAsync(500); + expect(signals).toHaveLength(1); + }); + + it('never reports an abort as an error', async () => { + const send = vi.fn(async () => { + throw Object.assign(new Error('cancelled'), { name: 'AbortError' }); + }); + new PreviewRequester({ session, send, onSettled, onError }).flush({ constraints: [] }); + await vi.runAllTimersAsync(); + expect(onError).not.toHaveBeenCalled(); + expect(onSettled).not.toHaveBeenCalled(); + }); + + it('does report a real failure', async () => { + const send = vi.fn(async () => { + throw new Error('500'); + }); + new PreviewRequester({ session, send, onSettled, onError }).flush({ constraints: [] }); + await vi.runAllTimersAsync(); + expect(onError).toHaveBeenCalledTimes(1); + }); +}); + +describe('out-of-order replies', () => { + it('drops a late reply for an earlier drag', async () => { + const { send, pending } = deferredTransport(); + const requester = new PreviewRequester({ session, send, onSettled }); + requester.flush({ constraints: [] }); // previewSeq 1 + requester.flush({ constraints: [] }); // previewSeq 2 + expect(pending.map((item) => item.seq)).toEqual([1, 2]); + // The newer answer lands first, then the older one arrives late. + pending[1].resolve(preview(2)); + await vi.advanceTimersByTimeAsync(0); + pending[0].resolve(preview(1)); + await vi.advanceTimersByTimeAsync(0); + expect(onSettled).toHaveBeenCalledTimes(1); + expect(session.lastValidPreview()?.previewSeq).toBe(2); + }); + + it('stamps a monotonic previewSeq per request', () => { + const { send, pending } = deferredTransport(); + const requester = new PreviewRequester({ session, send, onSettled }); + requester.flush({ constraints: [] }); + requester.flush({ constraints: [] }); + requester.flush({ constraints: [] }); + expect(pending.map((item) => item.seq)).toEqual([1, 2, 3]); + }); +}); + +describe('request payload', () => { + it('forwards operations alongside constraints', async () => { + // The 400 this guards against: constraints sent without the operations their + // provenance references. The requester must pass the whole draft through. + const sent: unknown[] = []; + const send = vi.fn(async (request) => { + sent.push(request); + return preview(request.previewSeq); + }); + const draft: PreviewDraft = { + constraints: [ + { + id: 'c1', + kind: 'road-edge-offset', + anchor: { type: 'road-interval', roadId: 'road:1', startStation: 0.2, endStation: 0.8, side: 'left' }, + anchorSnapshot: { coordinate: [0, 0], tangentAzimuth: 0, roadLengthMeters: 100, osmNodeIds: ['1'] }, + value: { offsetMeters: 1 }, + enabled: true, + status: 'exact', + provenance: { operationId: 'op1', createdAt: 'now' }, + }, + ], + operations: [{ id: 'op1', createdAt: 'now', constraintIds: ['c1'] }], + }; + new PreviewRequester({ session, send, onSettled }).flush(draft); + await vi.runAllTimersAsync(); + expect(sent).toHaveLength(1); + const request = sent[0] as { previewSeq: number; constraints?: unknown[]; operations?: Array<{ id: string }> }; + expect(request.previewSeq).toBe(1); + expect(request.constraints).toHaveLength(1); + expect(request.operations?.map((item) => item.id)).toEqual(['op1']); + }); +}); + +describe('blocked drafts', () => { + it('reports the outcome so the caller can show diagnostics', async () => { + const error: EditDiagnostic = { id: 'd', message: '车道过窄', rule: 'min-lane-width', severity: 'error' }; + const send = vi.fn(async (request) => preview(request.previewSeq, [error])); + new PreviewRequester({ session, send, onSettled }).flush({ constraints: [] }); + await vi.runAllTimersAsync(); + expect(onSettled).toHaveBeenCalledWith({ applied: true, blocked: true }, expect.anything()); + // The last valid preview must stay on screen. + expect(session.lastValidPreview()).toBeNull(); + }); +}); diff --git a/workbench/client/src/edit/preview-request.ts b/workbench/client/src/edit/preview-request.ts new file mode 100644 index 0000000..dfe3cbe --- /dev/null +++ b/workbench/client/src/edit/preview-request.ts @@ -0,0 +1,96 @@ +// Preview requests: debounce, cancel, and hand the response to EditSession. +// +// This layer only sends and cancels. Which responses count is `EditSession`'s +// call — `acceptPreview()` owns the previewSeq watermark — because that decision +// is the one worth unit-testing, and it must not depend on network timing. +// +// The transport is injected so the whole thing runs in node with fake timers. + +import type { EditSession, PreviewOutcome } from './session'; +import type { EditPreviewRequest, EditPreviewResponse } from './types'; + +/** Trailing debounce while a drag is live, per design.md's latency budget. */ +export const PREVIEW_DEBOUNCE_MS = 80; + +export type PreviewTransport = (request: EditPreviewRequest, signal: AbortSignal) => Promise; + +/** + * One gesture's document fragment. Operations travel with constraints because + * `validateEditDocument()` rejects a constraint whose `provenance.operationId` is + * not a recorded operation — constraints alone come back as a 400. + */ +export type PreviewDraft = Pick; + +export interface PreviewRequesterOptions { + session: EditSession; + send: PreviewTransport; + /** Called for every response the session accepted, stale ones excluded. */ + onSettled: (outcome: PreviewOutcome, response: EditPreviewResponse) => void; + /** Real failures only; an abort is expected and never reported. */ + onError?: (error: unknown) => void; + debounceMs?: number; +} + +function isAbort(error: unknown): boolean { + return error instanceof DOMException ? error.name === 'AbortError' : (error as Error)?.name === 'AbortError'; +} + +export class PreviewRequester { + private timer: ReturnType | null = null; + private inFlight: AbortController | null = null; + private readonly debounceMs: number; + + constructor(private readonly options: PreviewRequesterOptions) { + this.debounceMs = options.debounceMs ?? PREVIEW_DEBOUNCE_MS; + } + + /** During a drag: coalesce to one request per debounce window. */ + schedule(draft: PreviewDraft): void { + if (this.timer) clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.timer = null; + void this.dispatch(draft); + }, this.debounceMs); + } + + /** + * On pointerup: send now. The value the pointer settled on is what the user + * meant, so it must not be dropped by a pending timer or lost to the debounce. + */ + flush(draft: PreviewDraft): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + void this.dispatch(draft); + } + + /** Drops the pending timer and aborts anything in flight. */ + cancel(): void { + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.inFlight?.abort(); + this.inFlight = null; + } + + private async dispatch(draft: PreviewDraft): Promise { + // Abort the older request before starting a newer one: its answer is already + // obsolete, and leaving it running wastes a solve the user cannot see. + this.inFlight?.abort(); + const controller = new AbortController(); + this.inFlight = controller; + const previewSeq = this.options.session.nextPreviewSeq(); + try { + const response = await this.options.send({ previewSeq, ...draft }, controller.signal); + const outcome = this.options.session.acceptPreview(response); + // A stale response is dropped silently; that is the arbitration working. + if (outcome.applied) this.options.onSettled(outcome, response); + } catch (error) { + if (!isAbort(error)) this.options.onError?.(error); + } finally { + if (this.inFlight === controller) this.inFlight = null; + } + } +} diff --git a/workbench/client/src/edit/projection.test.ts b/workbench/client/src/edit/projection.test.ts index d5079d0..8a14ad6 100644 --- a/workbench/client/src/edit/projection.test.ts +++ b/workbench/client/src/edit/projection.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { fromLonLat, offsetCoordinate, type Coordinate } from './meters'; +import { fromLonLat, haversineMeters, offsetCoordinate, type Coordinate } from './meters'; import { anchorSnapshotFor, constraintValueFor, draftConstraint, + handlePositionFor, insideReserve, + operationFor, MIN_INTERVAL_STATION, projectHandleValue, projectIntervalEnd, @@ -16,6 +18,14 @@ const CENTER: Coordinate = [116.397, 39.908]; /** The road heads due north, so the manifest reports normal = tangent + 90 = east. */ const AXIS = 90; const TANGENT = 0; +/** + * Outward direction per side. The manifest's axis is `tangent + 90`, which is the + * geometry compiler's *right* (`offsetLine()` offsets counter-clockwise from the + * direction of travel). Naming them geographically keeps these tests from being + * re-derived from axis signs every time. + */ +const OUTWARD_RIGHT = AXIS; +const OUTWARD_LEFT = AXIS + 180; function interval(side?: Side, boundaryIndex?: number): RoadIntervalAnchor { return { @@ -47,33 +57,36 @@ function dragBy(azimuth: number, meters: number): [Coordinate, Coordinate] { } describe('road-edge-offset projection', () => { - it('widens when the left handle is dragged along the axis', () => { - const handle = makeHandle('road-edge-offset', interval('left')); - expect(projectHandleValue(handle, ...dragBy(AXIS, 2))).toBeCloseTo(2, 3); + it('widens whichever side is dragged away from the centreline', () => { + // The bug this pins: the left handle used to be drawn over the right kerb, so + // dragging the visually-left handle moved the right edge. Both sides must read + // as a positive offset when pulled outward. + expect( + projectHandleValue(makeHandle('road-edge-offset', interval('left')), ...dragBy(OUTWARD_LEFT, 2)), + ).toBeCloseTo(2, 3); + expect( + projectHandleValue(makeHandle('road-edge-offset', interval('right')), ...dragBy(OUTWARD_RIGHT, 2)), + ).toBeCloseTo(2, 3); }); - it('widens when the right handle is dragged the opposite way', () => { - // The right handle sits at tangent - 90 while the manifest still reports - // tangent + 90 as its axis, so "outward" is the negative axis direction. - // Both sides must read as a positive offset, or one of them drags inverted. - const handle = makeHandle('road-edge-offset', interval('right')); - expect(projectHandleValue(handle, ...dragBy(AXIS + 180, 2))).toBeCloseTo(2, 3); - }); - - it('narrows when the right handle is dragged inward', () => { - const handle = makeHandle('road-edge-offset', interval('right')); - expect(projectHandleValue(handle, ...dragBy(AXIS, 2))).toBeCloseTo(-2, 3); + it('narrows whichever side is dragged toward the centreline', () => { + expect( + projectHandleValue(makeHandle('road-edge-offset', interval('left')), ...dragBy(OUTWARD_RIGHT, 2)), + ).toBeCloseTo(-2, 3); + expect( + projectHandleValue(makeHandle('road-edge-offset', interval('right')), ...dragBy(OUTWARD_LEFT, 2)), + ).toBeCloseTo(-2, 3); }); it('adds to the value the manifest already reported', () => { const handle = makeHandle('road-edge-offset', interval('left'), 1.5); - expect(projectHandleValue(handle, ...dragBy(AXIS, 2))).toBeCloseTo(3.5, 3); + expect(projectHandleValue(handle, ...dragBy(OUTWARD_LEFT, 2))).toBeCloseTo(3.5, 3); }); it('clamps to the manifest range instead of extrapolating', () => { const handle = makeHandle('road-edge-offset', interval('left'), 0, -1, 1); - expect(projectHandleValue(handle, ...dragBy(AXIS, 9))).toBeCloseTo(1, 6); - expect(projectHandleValue(handle, ...dragBy(AXIS + 180, 9))).toBeCloseTo(-1, 6); + expect(projectHandleValue(handle, ...dragBy(OUTWARD_LEFT, 9))).toBeCloseTo(1, 6); + expect(projectHandleValue(handle, ...dragBy(OUTWARD_RIGHT, 9))).toBeCloseTo(-1, 6); }); it('ignores drag perpendicular to the axis', () => { @@ -85,13 +98,13 @@ describe('road-edge-offset projection', () => { describe('road-sidewalk-width projection', () => { it('grows outward and shrinks inward', () => { const handle = makeHandle('road-sidewalk-width', interval('left'), 2, 0, 8); - expect(projectHandleValue(handle, ...dragBy(AXIS, 1.5))).toBeCloseTo(3.5, 3); - expect(projectHandleValue(handle, ...dragBy(AXIS + 180, 1.5))).toBeCloseTo(0.5, 3); + expect(projectHandleValue(handle, ...dragBy(OUTWARD_LEFT, 1.5))).toBeCloseTo(3.5, 3); + expect(projectHandleValue(handle, ...dragBy(OUTWARD_RIGHT, 1.5))).toBeCloseTo(0.5, 3); }); it('never produces a negative width', () => { const handle = makeHandle('road-sidewalk-width', interval('right'), 2, 0, 8); - const scalar = projectHandleValue(handle, ...dragBy(AXIS, 10)); + const scalar = projectHandleValue(handle, ...dragBy(OUTWARD_LEFT, 10)); expect(scalar).toBeCloseTo(0, 6); expect(constraintValueFor(handle, scalar)).toEqual({ widthMeters: 0, transition: 'smoothstep' }); }); @@ -133,6 +146,52 @@ describe('road-lane-divider projection', () => { }); }); +describe('handle position derived from the value', () => { + it('sits where the drag put it while inside the range', () => { + const handle = makeHandle('road-edge-offset', interval('left')); + const value = projectHandleValue(handle, ...dragBy(OUTWARD_LEFT, 2)); + expect(haversineMeters(CENTER, handlePositionFor(handle, value))).toBeCloseTo(2, 3); + }); + + it('stops at the clamp bound instead of following the cursor', () => { + // The probe's decisive failure: ol-ext translated its proxy by the raw + // pointer delta, so a drag reading -24.1 m left the handle 24 m out while the + // constraint clamped at -5.4 m — the handle pointed at a road shape that + // cannot exist. The position must come from the clamped value. + const handle = makeHandle('road-edge-offset', interval('left'), 0, -1, 1); + const value = projectHandleValue(handle, ...dragBy(OUTWARD_LEFT, 9)); + expect(value).toBeCloseTo(1, 6); + expect(haversineMeters(CENTER, handlePositionFor(handle, value))).toBeCloseTo(1, 3); + }); + + it('moves outward on the side the handle belongs to', () => { + const left = makeHandle('road-edge-offset', interval('left')); + const right = makeHandle('road-edge-offset', interval('right')); + // Same positive value, opposite geographic directions. + const leftPosition = handlePositionFor(left, 2); + const rightPosition = handlePositionFor(right, 2); + // Left is the compiler's `heading - 90`, i.e. west of a north-heading road. + expect(leftPosition[0]).toBeLessThan(CENTER[0]); + expect(rightPosition[0]).toBeGreaterThan(CENTER[0]); + expect(haversineMeters(CENTER, leftPosition)).toBeCloseTo(2, 3); + expect(haversineMeters(CENTER, rightPosition)).toBeCloseTo(2, 3); + }); + + it('leaves the handle alone when the value has not changed', () => { + const handle = makeHandle('road-edge-offset', interval('left'), 1.5); + expect(haversineMeters(CENTER, handlePositionFor(handle, 1.5))).toBeCloseTo(0, 6); + }); + + it('round-trips an arbitrary in-range drag', () => { + const handle = makeHandle('road-edge-offset', interval('left'), 1, -5, 5); + for (const meters of [-3, -0.5, 0.75, 3.5]) { + const value = projectHandleValue(handle, ...dragBy(OUTWARD_LEFT, meters)); + expect(value).toBeCloseTo(1 + meters, 3); + expect(haversineMeters(CENTER, handlePositionFor(handle, value))).toBeCloseTo(Math.abs(meters), 3); + } + }); +}); + describe('interval range projection', () => { const window = { minStation: 0.1, maxStation: 0.9 }; const LENGTH = 1000; @@ -224,4 +283,29 @@ describe('draft constraint', () => { const handle = makeHandle('road-edge-offset', interval('left')); expect(anchorSnapshotFor(handle, centerline, []).tangentAzimuth).toBeCloseTo(0, 6); }); + + it('produces the operation the constraint references', () => { + // The first wiring of the preview request sent constraints alone and the + // server answered 400: validateEditDocument() rejects a constraint whose + // provenance.operationId is not a recorded operation. The two must be built + // together, so this pins the link. + const handle = makeHandle('road-edge-offset', interval('left')); + const snapshot = anchorSnapshotFor(handle, centerline, []); + const constraint = draftConstraint(handle, handle.anchor, { offsetMeters: 2 }, snapshot, identity); + const operation = operationFor(constraint); + expect(operation.id).toBe(constraint.provenance.operationId); + expect(operation.createdAt).toBe(constraint.provenance.createdAt); + expect(operation.constraintIds).toEqual([constraint.id]); + expect(operation).not.toHaveProperty('author'); + }); + + it('carries the author through when one is recorded', () => { + const handle = makeHandle('road-edge-offset', interval('left')); + const snapshot = anchorSnapshotFor(handle, centerline, []); + const constraint = draftConstraint(handle, handle.anchor, { offsetMeters: 2 }, snapshot, { + ...identity, + author: 'dingkang', + }); + expect(operationFor(constraint).author).toBe('dingkang'); + }); }); diff --git a/workbench/client/src/edit/projection.ts b/workbench/client/src/edit/projection.ts index 3a41e7b..6f7e58e 100644 --- a/workbench/client/src/edit/projection.ts +++ b/workbench/client/src/edit/projection.ts @@ -6,13 +6,21 @@ // own range, and packaged as a `native-road-edits/v2` value. The client never // writes a coordinate into a road polygon. -import { clamp, haversineMeters, polylineLengthMeters, signedMetersAlongAxis, type Coordinate } from './meters'; +import { + clamp, + haversineMeters, + offsetCoordinate, + polylineLengthMeters, + signedMetersAlongAxis, + type Coordinate, +} from './meters'; import type { AnchorSnapshot, ConstraintValue, EditHandle, JunctionReserve, RoadConstraint, + RoadEditOperation, RoadIntervalAnchor, Transition, } from './types'; @@ -28,17 +36,21 @@ export interface IntervalWindow { /** * Turns "along axisAzimuth" into "outward" for a handle. * - * `makeRoadHandles()` places the left handle at `tangent + 90` and the right one - * at `tangent - 90`, but reports `axisAzimuth = tangent + 90` for both. Since a - * positive `offsetMeters` / `widthMeters` always widens the road, a right-side - * drag measured along that axis has to be negated. Lane dividers are the - * exception: their offset is a signed lateral position already measured along - * the same axis, so the raw projection is the value. + * The manifest reports `axisAzimuth = tangent + 90` for both sides, which is the + * geometry compiler's *right*: `offsetLine()` treats a positive offset as + * counter-clockwise from the direction of travel, and sidewalks are placed at + * `heading + (side === 'left' ? -90 : 90)`. So the left kerb lies along the + * negative axis, and a left-side drag has to be negated for a positive + * `offsetMeters` / `widthMeters` to mean "wider" on both sides. + * + * Lane dividers are the exception: their offset is a signed lateral position + * already measured along the axis (left negative, right positive), so the raw + * projection is the value. */ function outwardSign(handle: EditHandle): number { if (handle.kind === 'road-lane-divider') return 1; const side = 'side' in handle.anchor ? handle.anchor.side : undefined; - return side === 'right' ? -1 : 1; + return side === 'left' ? -1 : 1; } /** @@ -50,6 +62,21 @@ export function projectHandleValue(handle: EditHandle, from: Coordinate, to: Coo return clamp(handle.value.current + delta, handle.value.min, handle.value.max); } +/** + * Where the handle belongs for a given constraint value — the inverse of + * `projectHandleValue`, in EPSG:4326. + * + * This is what stops a handle running away from the geometry it controls. The + * ol-ext probe translated its proxy by the raw pointer delta, so a drag reading + * -24.1 m left the handle 24 m out while the constraint clamped at -5.4 m: the + * handle pointed at a road shape that could never exist. Deriving the position + * from the clamped value instead pins the handle to the limit. + */ +export function handlePositionFor(handle: EditHandle, value: number): Coordinate { + const outward = outwardSign(handle) < 0 ? handle.axisAzimuth + 180 : handle.axisAzimuth; + return offsetCoordinate(handle.position, outward, value - handle.value.current); +} + /** The manifest hangs `boundaryIndex` on the anchor; the document needs it in `value`. */ function boundaryIndexOf(handle: EditHandle): number { const index = 'boundaryIndex' in handle.anchor ? handle.anchor.boundaryIndex : undefined; @@ -141,6 +168,23 @@ export function anchorSnapshotFor(handle: EditHandle, centerline: Coordinate[], }; } +/** + * The operation a drafted constraint has to travel with. + * + * `validateEditDocument()` rejects any constraint whose `provenance.operationId` + * is not a recorded operation, so sending constraints alone is a 400 — which is + * exactly how the first wiring of the preview request failed. Every caller that + * ships a constraint must ship this alongside it. + */ +export function operationFor(constraint: RoadConstraint): RoadEditOperation { + return { + id: constraint.provenance.operationId, + createdAt: constraint.provenance.createdAt, + constraintIds: [constraint.id], + ...(constraint.provenance.author ? { author: constraint.provenance.author } : {}), + }; +} + export interface DraftIdentity { constraintId: string; operationId: string; diff --git a/workbench/client/src/edit/selection.test.ts b/workbench/client/src/edit/selection.test.ts index 1d5eabe..365fe18 100644 --- a/workbench/client/src/edit/selection.test.ts +++ b/workbench/client/src/edit/selection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { disabledReasonOf, handlesForSegment, reservesForSegment } from './selection'; +import type { Coordinate } from './meters'; +import { disabledReasonOf, handlesForSegment, intervalRangeHandles, reservesForSegment } from './selection'; import type { ConstraintKind, EditHandle, HandleManifest, SemanticAnchor } from './types'; function handle(handleId: string, kind: ConstraintKind, anchor: SemanticAnchor, editable = true): EditHandle { @@ -97,6 +98,74 @@ describe('reservesForSegment', () => { }); }); +describe('intervalRangeHandles', () => { + // Heads due north, about 1112 m long. + const centerline: Coordinate[] = [ + [116.397, 39.9], + [116.397, 39.91], + ]; + const reserves = manifest.reserves; + const parent = handle('h:edge:a', 'road-edge-offset', roadInterval('road:way/1:forward')); + + it('produces one handle per end of the affected interval', () => { + const ends = intervalRangeHandles(parent, centerline, reserves, 'seg:1'); + expect(ends.map((item) => item.end)).toEqual(['start', 'end']); + expect(ends.map((item) => item.station)).toEqual([0.15, 0.85]); + }); + + it('derives ids from the parent so the pair stays traceable', () => { + const ends = intervalRangeHandles(parent, centerline, reserves, 'seg:1'); + expect(ends.map((item) => item.handleId)).toEqual(['h:edge:a:range:start', 'h:edge:a:range:end']); + expect(ends.every((item) => item.parentHandleId === 'h:edge:a')).toBe(true); + }); + + it('carries the reserve-free window, not the whole road', () => { + const [start] = intervalRangeHandles(parent, centerline, reserves, 'seg:1'); + // The two reserves on seg:1 leave 0.15..0.85, which is exactly the interval + // the solver anchored — a range drag may shrink it but never grow past this. + expect(start.window).toEqual({ minStation: 0.15, maxStation: 0.85 }); + }); + + it('places the ends apart, along the road', () => { + const [start, end] = intervalRangeHandles(parent, centerline, reserves, 'seg:1'); + expect(start.position[1]).toBeLessThan(end.position[1]); + expect(start.position[0]).toBeCloseTo(116.397, 6); + // Drag axis is the road direction, which is due north here. + expect(start.tangentAzimuth).toBeCloseTo(0, 3); + expect(start.roadLengthMeters).toBeCloseTo(end.roadLengthMeters, 6); + expect(start.roadLengthMeters).toBeGreaterThan(1000); + }); + + it('offers nothing for a handle that cannot be dragged', () => { + const blocked = handle('h:blocked', 'road-edge-offset', roadInterval('road:way/1:forward'), false); + expect(intervalRangeHandles(blocked, centerline, reserves, 'seg:1')).toEqual([]); + }); + + it('offers nothing for a junction anchor', () => { + const approach = handle('h:approach', 'junction-approach-width', { + type: 'junction-approach', + nodeId: '9', + segmentId: 'seg:1', + }); + expect(intervalRangeHandles(approach, centerline, reserves, 'seg:1')).toEqual([]); + }); + + it('offers nothing for a road with no length', () => { + expect(intervalRangeHandles(parent, [[116.397, 39.9]], reserves, 'seg:1')).toEqual([]); + expect( + intervalRangeHandles( + parent, + [ + [116.397, 39.9], + [116.397, 39.9], + ], + reserves, + 'seg:1', + ), + ).toEqual([]); + }); +}); + describe('disabledReasonOf', () => { it('says nothing for an editable handle', () => { expect(disabledReasonOf(handle('h', 'road-edge-offset', roadInterval('road:way/1:forward')))).toBeUndefined(); diff --git a/workbench/client/src/edit/selection.ts b/workbench/client/src/edit/selection.ts index de89fdf..14744bc 100644 --- a/workbench/client/src/edit/selection.ts +++ b/workbench/client/src/edit/selection.ts @@ -4,7 +4,15 @@ // be unit-tested in node. `handle-layer.ts` is the OL adapter that renders the // result. -import { isRoadKind, type EditHandle, type HandleManifest, type JunctionReserve } from './types'; +import { coordinateAtStation, polylineLengthMeters, tangentAzimuthAt, type Coordinate } from './meters'; +import { reserveWindow, type IntervalWindow } from './projection'; +import { + isRoadKind, + type EditHandle, + type HandleManifest, + type JunctionReserve, + type RoadIntervalAnchor, +} from './types'; /** * Road kinds only, and only for the selected road's segment. @@ -41,3 +49,77 @@ export function disabledReasonOf(handle: EditHandle): string | undefined { if (handle.editable) return undefined; return handle.disabledReason || '该手柄位于路口保留区,请进入 JunctionTools 编辑。'; } + +/** + * The two ends of a road handle's affected interval, as draggable range handles. + * + * These are synthesised on the client rather than published in the manifest, and + * that is a deliberate deviation from design.md's "客户端不自行推导手柄位置": + * + * - `EditHandle.kind` is a `RoadConstraintKind`, and design.md declares those six + * complete with no gaps. A range end is not a constraint — it moves the anchor + * of an existing one — so it has no kind to carry. + * - Its position is a pure function of data the server already sent: the anchor's + * stations, the reserve window, and the centerline. Echoing it back would be the + * second cursor the cross-layer guide warns about. + * - The ghost has to interpolate stations to coordinates anyway, to draw the + * affected band along the road, so the capability exists client-side regardless. + * + * No semantics are invented here: the legal window comes from `reserves`, the + * interval comes from the anchor the solver built, and the axis is the road's own + * tangent. Only the screen position is derived. + */ +export interface IntervalRangeHandle { + handleId: string; + /** The road handle whose anchor interval this end belongs to. */ + parentHandleId: string; + roadId: string; + end: 'start' | 'end'; + station: number; + /** EPSG:4326, interpolated along the centerline. */ + position: Coordinate; + /** Drag axis: the direction the road runs at this station. */ + tangentAzimuth: number; + window: IntervalWindow; + roadLengthMeters: number; + /** The interval being resized, so a drag can call `projectIntervalEnd()` directly. */ + anchor: RoadIntervalAnchor; + /** Kept so the ghost can draw the affected band along the road. */ + centerline: Coordinate[]; +} + +export function intervalRangeHandles( + handle: EditHandle, + centerline: Coordinate[], + reserves: JunctionReserve[], + segmentId: string, +): IntervalRangeHandle[] { + if (!handle.editable || handle.anchor.type !== 'road-interval') return []; + const roadLengthMeters = polylineLengthMeters(centerline); + if (!(roadLengthMeters > 0)) return []; + const window = reserveWindow(reserves, segmentId); + const anchor = handle.anchor; + const ends: Array<{ end: 'start' | 'end'; station: number }> = [ + { end: 'start', station: anchor.startStation }, + { end: 'end', station: anchor.endStation }, + ]; + const handles: IntervalRangeHandle[] = []; + for (const { end, station } of ends) { + const position = coordinateAtStation(centerline, station); + if (!position) continue; + handles.push({ + handleId: `${handle.handleId}:range:${end}`, + parentHandleId: handle.handleId, + roadId: anchor.roadId, + end, + station, + position, + tangentAzimuth: tangentAzimuthAt(centerline, station), + window, + roadLengthMeters, + anchor, + centerline, + }); + } + return handles; +}