feat: drag road handles with ghost and server preview
Three things, kept in one commit because they touch overlapping hunks of the same two files and this environment has no interactive hunk staging. Splitting them by file would have drawn boundaries that misrepresent what changed. 1. Step 4 of the map editor. A native OpenLayers PointerInteraction turns a drag into a clamped constraint value, the ghost source shows it immediately, and the solver's answer replaces a parallel set of preview layers while the baseline layers are hidden rather than overwritten. Preview requests debounce at 80 ms, pointerup flushes without waiting, and a newer request aborts the one in flight; EditSession decides which answers count. Handle positions come from the clamped value, so a handle stops at its limit instead of following the cursor. Three of the four drag capabilities are live: edge offset, sidewalk width, lane divider. 2. Road edge handles were drawn on the wrong side. offsetLine() offsets counter-clockwise from the direction of travel and sidewalks use `heading + (side === 'left' ? -90 : 90)`, so left is `tangent - 90`; makeRoadHandles() placed the left handle at `tangent + 90`, over the right kerb. Dragging the visually-left handle moved the right edge. Fixed on both sides of the wire, with regression tests that name the sides geographically rather than by axis sign. 3. Roads the junctions geometrically fill are now read-only. The 0.45 cap per reserve made the existing `unavailable` branch unreachable, so a 14.5 m stub between two junctions was offered a 1.5 m editable band with no room for the transitions a road-interval constraint needs. Greying only affects the manifest: constraints already saved against such a road keep being solved, so the geometry output is unchanged and the fixture baselines do not move. Range handles are built and unit-tested but hidden behind `intervalEditingSupported`: compileGeometry() reads neither profile.interval nor profile.transitions, so every edit applies to the whole road and the control would have had no effect. Recorded in research/interval-not-applied.md, which also blocks one PRD acceptance criterion. The ol-ext probe stays in the tree as a manual harness; ol-ext is still not a dependency. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 的区间带还要用。
|
||||||
@@ -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` 任务范围。
|
||||||
@@ -22,7 +22,8 @@
|
|||||||
"08-26-direct-edit-documents",
|
"08-26-direct-edit-documents",
|
||||||
"08-26-direct-edit-solver-api",
|
"08-26-direct-edit-solver-api",
|
||||||
"08-26-direct-edit-map-editor",
|
"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,
|
"parent": null,
|
||||||
"relatedFiles": [
|
"relatedFiles": [
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
|
||||||
68
.trellis/tasks/08-27-junction-dominated-roads/implement.md
Normal file
68
.trellis/tasks/08-27-junction-dominated-roads/implement.md
Normal file
@@ -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` 即可回到当前行为。
|
||||||
|
客户端不需要任何配合改动,回滚后灰色手柄自然消失,回到「空真」状态。
|
||||||
87
.trellis/tasks/08-27-junction-dominated-roads/prd.md
Normal file
87
.trellis/tasks/08-27-junction-dominated-roads/prd.md
Normal file
@@ -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 步交付并有单测覆盖,本任务只补上服务端的输入。
|
||||||
26
.trellis/tasks/08-27-junction-dominated-roads/task.json
Normal file
26
.trellis/tasks/08-27-junction-dominated-roads/task.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -482,11 +482,25 @@ function makeRoadHandles(model, groups, reserves, constraints) {
|
|||||||
const unavailable = start >= end;
|
const unavailable = start >= end;
|
||||||
const width = roads.reduce((sum, item) => sum + (Number(item.widthMeters) || 0), 0);
|
const width = roads.reduce((sum, item) => sum + (Number(item.widthMeters) || 0), 0);
|
||||||
const laneCount = roads.reduce((sum, item) => sum + (Number(item.laneCount) || 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 point = coordinateAt(road.centerline, station);
|
||||||
const tangent = tangentAzimuth(road.centerline, station);
|
const tangent = tangentAzimuth(road.centerline, station);
|
||||||
const normal = (tangent + 90) % 360;
|
const normal = (tangent + 90) % 360;
|
||||||
const interval = { type: 'road-interval', roadId: road.id, startStation: start, endStation: end };
|
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 add = (kind, anchor, value, min, max, axis, position, affects, constraint) => {
|
||||||
const disabledReason = baseDisabled;
|
const disabledReason = baseDisabled;
|
||||||
handles.push({
|
handles.push({
|
||||||
@@ -503,7 +517,12 @@ function makeRoadHandles(model, groups, reserves, constraints) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
for (const side of ['left', 'right']) {
|
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 edgeAnchor = { ...interval, side };
|
||||||
const edgeConstraint = constraintFor(constraints, 'road-edge-offset', edgeAnchor);
|
const edgeConstraint = constraintFor(constraints, 'road-edge-offset', edgeAnchor);
|
||||||
add(
|
add(
|
||||||
|
|||||||
@@ -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.every((handle) => Number.isFinite(handle.axisAzimuth)));
|
||||||
assert.ok(junctionResolution.handles.handles.some((handle) => handle.affects.includes('junction:node/junction')));
|
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({
|
const anchored = constraint({
|
||||||
id: 'edge-on-road-a',
|
id: 'edge-on-road-a',
|
||||||
anchor: { type: 'road-interval', roadId: 'road:a', startStation: 0.2, endStation: 0.8, side: 'left' },
|
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', '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.
|
// 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');
|
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')"])
|
for (const forbidden of ["require('fs')", "require('path')", "require('http')", "require('https')"])
|
||||||
|
|||||||
@@ -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 { Download, RefreshCw, Save, Upload } from 'lucide-react';
|
||||||
import { api } from './lib/api';
|
import { api } from './lib/api';
|
||||||
import { MapCanvas } from './components/MapCanvas';
|
import { MapCanvas } from './components/MapCanvas';
|
||||||
import { directEditEnabled } from './edit/flag';
|
import { directEditEnabled, intervalEditingSupported } from './edit/flag';
|
||||||
import { disabledReasonOf, handlesForSegment } from './edit/selection';
|
import { PreviewRequester, type PreviewDraft } from './edit/preview-request';
|
||||||
import type { HandleManifest } from './edit/types';
|
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 { 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';
|
import type { LayerName } from './map/layers';
|
||||||
|
|
||||||
const layerLabels: Array<[LayerName, string]> = [
|
const layerLabels: Array<[LayerName, string]> = [
|
||||||
@@ -99,12 +110,21 @@ function App() {
|
|||||||
api
|
api
|
||||||
.editState()
|
.editState()
|
||||||
.then((value) => {
|
.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
|
// A manifest failure must not take the workbench down with it; the map just
|
||||||
// shows no handles.
|
// shows no handles.
|
||||||
.catch((error: Error) => setStatus(`直接编辑手柄不可用:${error.message}`));
|
.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(() => {
|
const editHandles = useMemo(() => {
|
||||||
if (!directEditEnabled || !state) return [];
|
if (!directEditEnabled || !state) return [];
|
||||||
// Handle anchors carry a directional road id while reserves are keyed by
|
// 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.
|
// boundary, so the first blocked handle explains itself in the header.
|
||||||
const blockedHandle = editHandles.find((handle) => !handle.editable);
|
const blockedHandle = editHandles.find((handle) => !handle.editable);
|
||||||
const blockedReason = blockedHandle ? disabledReasonOf(blockedHandle) : undefined;
|
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<Partial<Record<PreviewLayerName, GeoJson | null>> | null>(null);
|
||||||
|
const [editDiagnostics, setEditDiagnostics] = useState<EditDiagnostic[]>([]);
|
||||||
|
// 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<RoadConstraint[]>([]);
|
||||||
|
/** Their operations. A constraint without its operation is rejected as a 400. */
|
||||||
|
const savedOperations = useRef<RoadEditOperation[]>([]);
|
||||||
|
const sessionRef = useRef<EditSession | null>(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) =>
|
const stage = (change: Override) =>
|
||||||
setStaged((current) => [...current.filter((item) => item.id !== change.id), change]);
|
setStaged((current) => [...current.filter((item) => item.id !== change.id), change]);
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
@@ -180,12 +290,13 @@ function App() {
|
|||||||
{staged.length ? `未保存修改 ${staged.length} 项` : '所有修改已保存'}
|
{staged.length ? `未保存修改 ${staged.length} 项` : '所有修改已保存'}
|
||||||
</span>
|
</span>
|
||||||
{directEditEnabled ? (
|
{directEditEnabled ? (
|
||||||
<span>
|
<span className={editDiagnostics.some((item) => item.severity === 'error') ? 'dirty' : ''}>
|
||||||
{editHandles.length
|
{editDiagnostics.find((item) => item.severity === 'error')?.message ||
|
||||||
? `手柄 ${editHandles.length} 个`
|
(editHandles.length
|
||||||
: selected
|
? `手柄 ${editHandles.length} 个`
|
||||||
? '当前道路无可编辑手柄'
|
: selected
|
||||||
: '选中道路以显示手柄'}
|
? '当前道路无可编辑手柄'
|
||||||
|
: '选中道路以显示手柄')}
|
||||||
{blockedReason ? `|${blockedReason}` : ''}
|
{blockedReason ? `|${blockedReason}` : ''}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -251,6 +362,32 @@ function App() {
|
|||||||
onSelectRoad={setSelected}
|
onSelectRoad={setSelected}
|
||||||
onFeature={handleFeature}
|
onFeature={handleFeature}
|
||||||
handles={editHandles}
|
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);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Inspector
|
<Inspector
|
||||||
road={selected}
|
road={selected}
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ import Map from 'ol/Map';
|
|||||||
import View from 'ol/View';
|
import View from 'ol/View';
|
||||||
import Select from 'ol/interaction/Select';
|
import Select from 'ol/interaction/Select';
|
||||||
import { click } from 'ol/events/condition';
|
import { click } from 'ol/events/condition';
|
||||||
import type { Road, WorkbenchState } from '../types/state';
|
import type { GeoJson, Road, WorkbenchState } from '../types/state';
|
||||||
import { createLayers, updateLayers, updateSelectedRoad, type LayerName } from '../map/layers';
|
import { createLayers, updateLayers, updateSelectedRoad, type LayerName } from '../map/layers';
|
||||||
import { directEditEnabled } from '../edit/flag';
|
import { directEditEnabled } from '../edit/flag';
|
||||||
|
import { createHandleDragInteraction } from '../edit/drag-interaction';
|
||||||
|
import { EditGhostLayer } from '../edit/ghost-layer';
|
||||||
import { EditHandleLayer } from '../edit/handle-layer';
|
import { EditHandleLayer } from '../edit/handle-layer';
|
||||||
import type { EditHandle } from '../edit/types';
|
import { EditPreviewLayer } from '../edit/preview-layer';
|
||||||
|
import type { IntervalRangeHandle } from '../edit/selection';
|
||||||
|
import type { EditHandle, PreviewLayerName, RoadIntervalAnchor } from '../edit/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
state: WorkbenchState;
|
state: WorkbenchState;
|
||||||
@@ -18,12 +22,47 @@ interface Props {
|
|||||||
onFeature: (properties: Record<string, unknown>) => void;
|
onFeature: (properties: Record<string, unknown>) => void;
|
||||||
/** Already filtered to the selected road. Ignored unless `directEdit` is on. */
|
/** Already filtered to the selected road. Ignored unless `directEdit` is on. */
|
||||||
handles?: EditHandle[];
|
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<Record<PreviewLayerName, GeoJson | null>> | 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<HTMLDivElement>(null);
|
const target = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<Map | null>(null);
|
const mapRef = useRef<Map | null>(null);
|
||||||
const layersRef = useRef<ReturnType<typeof createLayers> | null>(null);
|
const layersRef = useRef<ReturnType<typeof createLayers> | null>(null);
|
||||||
const handleLayerRef = useRef<EditHandleLayer | null>(null);
|
const handleLayerRef = useRef<EditHandleLayer | null>(null);
|
||||||
|
const ghostRef = useRef<EditGhostLayer | null>(null);
|
||||||
|
const previewRef = useRef<EditPreviewLayer | null>(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<Road | null>(selected);
|
const selectedRef = useRef<Road | null>(selected);
|
||||||
const stateRef = useRef(state);
|
const stateRef = useRef(state);
|
||||||
const sceneRef = useRef(scene);
|
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
|
// With `directEdit` off nothing below exists: no extra source, no layer, no
|
||||||
// interaction — the canvas is byte-for-byte the shipped behaviour.
|
// interaction — the canvas is byte-for-byte the shipped behaviour.
|
||||||
const handleLayer = directEditEnabled ? new EditHandleLayer() : null;
|
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;
|
handleLayerRef.current = handleLayer;
|
||||||
|
ghostRef.current = ghost;
|
||||||
|
previewRef.current = previewLayer;
|
||||||
const map = new Map({
|
const map = new Map({
|
||||||
target: target.current,
|
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 }),
|
view: new View({ center: [0, 0], zoom: 2 }),
|
||||||
});
|
});
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
@@ -56,6 +109,21 @@ export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFea
|
|||||||
style: null,
|
style: null,
|
||||||
});
|
});
|
||||||
map.addInteraction(select);
|
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 listener = ({ selected: values }: { selected: import('ol/Feature').default[] }) => {
|
||||||
const properties = values[0]?.getProperties();
|
const properties = values[0]?.getProperties();
|
||||||
if (!properties) return;
|
if (!properties) return;
|
||||||
@@ -94,8 +162,17 @@ export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFea
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Null when the flag is off, so this whole path is inert on main.
|
// 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.
|
// Only the editHandles source is replaced; baseline layers are never touched.
|
||||||
handleLayerRef.current?.render(handles ?? []);
|
handleLayerRef.current?.render(handles ?? [], ranges ?? []);
|
||||||
}, [handles]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const layers = layersRef.current;
|
const layers = layersRef.current;
|
||||||
if (!layers) return;
|
if (!layers) return;
|
||||||
|
|||||||
133
workbench/client/src/edit/drag-interaction.ts
Normal file
133
workbench/client/src/edit/drag-interaction.ts
Normal file
@@ -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<PointerEvent | KeyboardEvent | WheelEvent>;
|
||||||
|
|
||||||
|
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<VectorSource>;
|
||||||
|
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<Active, { kind: 'range' }>, 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -29,3 +29,18 @@ function readFlag(): boolean {
|
|||||||
* halfway through and build a half-wired map.
|
* halfway through and build a half-wired map.
|
||||||
*/
|
*/
|
||||||
export const directEditEnabled = readFlag();
|
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;
|
||||||
|
|||||||
125
workbench/client/src/edit/ghost-layer.ts
Normal file
125
workbench/client/src/edit/ghost-layer.ts
Normal file
@@ -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<VectorSource>;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,10 @@ import VectorSource from 'ol/source/Vector';
|
|||||||
import CircleStyle from 'ol/style/Circle';
|
import CircleStyle from 'ol/style/Circle';
|
||||||
import Fill from 'ol/style/Fill';
|
import Fill from 'ol/style/Fill';
|
||||||
import Stroke from 'ol/style/Stroke';
|
import Stroke from 'ol/style/Stroke';
|
||||||
|
import RegularShape from 'ol/style/RegularShape';
|
||||||
import Style from 'ol/style/Style';
|
import Style from 'ol/style/Style';
|
||||||
import { fromLonLat } from './meters';
|
import { fromLonLat } from './meters';
|
||||||
import { disabledReasonOf } from './selection';
|
import { disabledReasonOf, type IntervalRangeHandle } from './selection';
|
||||||
import type { EditHandle } from './types';
|
import type { EditHandle } from './types';
|
||||||
|
|
||||||
/** The only property a handle feature carries. */
|
/** The only property a handle feature carries. */
|
||||||
@@ -29,6 +30,20 @@ const EDITABLE_STYLE: Record<string, Style> = {
|
|||||||
'road-lane-divider': handleStyle('#8f6fd0'),
|
'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. */
|
/** Reserve handles stay visible so the boundary is explainable, but read as inert. */
|
||||||
const DISABLED_STYLE = new Style({
|
const DISABLED_STYLE = new Style({
|
||||||
image: new CircleStyle({
|
image: new CircleStyle({
|
||||||
@@ -52,6 +67,7 @@ export class EditHandleLayer {
|
|||||||
readonly layer: VectorLayer<VectorSource>;
|
readonly layer: VectorLayer<VectorSource>;
|
||||||
private readonly source = new VectorSource();
|
private readonly source = new VectorSource();
|
||||||
private index = new Map<string, EditHandle>();
|
private index = new Map<string, EditHandle>();
|
||||||
|
private ranges = new Map<string, IntervalRangeHandle>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.layer = new VectorLayer({
|
this.layer = new VectorLayer({
|
||||||
@@ -59,19 +75,28 @@ export class EditHandleLayer {
|
|||||||
// Above every baseline layer, so a handle is never hidden under a surface.
|
// Above every baseline layer, so a handle is never hidden under a surface.
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
style: (feature) => {
|
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;
|
if (!handle) return undefined;
|
||||||
return handle.editable ? EDITABLE_STYLE[handle.kind] : DISABLED_STYLE;
|
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.index = new Map(handles.map((handle) => [handle.handleId, handle]));
|
||||||
|
this.ranges = new Map(ranges.map((range) => [range.handleId, range]));
|
||||||
this.source.clear();
|
this.source.clear();
|
||||||
this.source.addFeatures(
|
this.source.addFeatures(
|
||||||
handles.map(
|
[...handles, ...ranges].map(
|
||||||
(handle) =>
|
(handle) =>
|
||||||
new Feature({
|
new Feature({
|
||||||
geometry: new Point(fromLonLat(handle.position)),
|
geometry: new Point(fromLonLat(handle.position)),
|
||||||
@@ -83,9 +108,15 @@ export class EditHandleLayer {
|
|||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.index = new Map();
|
this.index = new Map();
|
||||||
|
this.ranges = new Map();
|
||||||
this.source.clear();
|
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. */
|
/** Manifest lookup — the single path from a rendered feature back to semantics. */
|
||||||
handle(handleId: string): EditHandle | undefined {
|
handle(handleId: string): EditHandle | undefined {
|
||||||
return this.index.get(handleId);
|
return this.index.get(handleId);
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
azimuthBetween,
|
||||||
|
coordinateAtStation,
|
||||||
fromLonLat,
|
fromLonLat,
|
||||||
haversineMeters,
|
haversineMeters,
|
||||||
mercatorUnitsForMeters,
|
mercatorUnitsForMeters,
|
||||||
offsetCoordinate,
|
offsetCoordinate,
|
||||||
polylineLengthMeters,
|
polylineLengthMeters,
|
||||||
signedMetersAlongAxis,
|
signedMetersAlongAxis,
|
||||||
|
tangentAzimuthAt,
|
||||||
toLonLat,
|
toLonLat,
|
||||||
type Coordinate,
|
type Coordinate,
|
||||||
} from './meters';
|
} 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', () => {
|
describe('spherical helpers', () => {
|
||||||
it('matches a known great-circle distance', () => {
|
it('matches a known great-circle distance', () => {
|
||||||
expect(haversineMeters([0, 0], [0, 1])).toBeCloseTo(111195, 0);
|
expect(haversineMeters([0, 0], [0, 1])).toBeCloseTo(111195, 0);
|
||||||
|
|||||||
@@ -110,3 +110,55 @@ export function offsetCoordinate(point: Coordinate, azimuth: number, meters: num
|
|||||||
export function clamp(value: number, min: number, max: number): number {
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
return Math.min(max, Math.max(min, value));
|
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]);
|
||||||
|
}
|
||||||
|
|||||||
97
workbench/client/src/edit/preview-layer.ts
Normal file
97
workbench/client/src/edit/preview-layer.ts
Normal file
@@ -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<PreviewLayerName, LayerName> = {
|
||||||
|
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<typeof createLayers>;
|
||||||
|
/** Baseline layers currently hidden because the preview supersedes them. */
|
||||||
|
private hidden = new Set<LayerName>();
|
||||||
|
|
||||||
|
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<Record<PreviewLayerName, GeoJson | null>>, baseline: ReturnType<typeof createLayers>): void {
|
||||||
|
const touched = new Set<LayerName>();
|
||||||
|
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<typeof createLayers>, visible: Partial<Record<LayerName, boolean>>): 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
194
workbench/client/src/edit/preview-request.test.ts
Normal file
194
workbench/client/src/edit/preview-request.test.ts
Normal file
@@ -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<EditPreviewResponse>((resolve) => pending.push({ seq: request.previewSeq, resolve }));
|
||||||
|
};
|
||||||
|
return { send, pending, signals };
|
||||||
|
}
|
||||||
|
|
||||||
|
let session: EditSession;
|
||||||
|
let onSettled: Mock<SettledHandler>;
|
||||||
|
let onError: Mock<ErrorHandler>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
session = new EditSession([]);
|
||||||
|
onSettled = vi.fn<SettledHandler>();
|
||||||
|
onError = vi.fn<ErrorHandler>();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('debounce', () => {
|
||||||
|
it('coalesces a burst of drag updates into one request', async () => {
|
||||||
|
const send = vi.fn<PreviewTransport>(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<PreviewTransport>(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<PreviewTransport>(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<PreviewTransport>(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<PreviewTransport>(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<PreviewTransport>(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<PreviewTransport>(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();
|
||||||
|
});
|
||||||
|
});
|
||||||
96
workbench/client/src/edit/preview-request.ts
Normal file
96
workbench/client/src/edit/preview-request.ts
Normal file
@@ -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<EditPreviewResponse>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<EditPreviewRequest, 'constraints' | 'operations'>;
|
||||||
|
|
||||||
|
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<typeof setTimeout> | 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<void> {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { fromLonLat, offsetCoordinate, type Coordinate } from './meters';
|
import { fromLonLat, haversineMeters, offsetCoordinate, type Coordinate } from './meters';
|
||||||
import {
|
import {
|
||||||
anchorSnapshotFor,
|
anchorSnapshotFor,
|
||||||
constraintValueFor,
|
constraintValueFor,
|
||||||
draftConstraint,
|
draftConstraint,
|
||||||
|
handlePositionFor,
|
||||||
insideReserve,
|
insideReserve,
|
||||||
|
operationFor,
|
||||||
MIN_INTERVAL_STATION,
|
MIN_INTERVAL_STATION,
|
||||||
projectHandleValue,
|
projectHandleValue,
|
||||||
projectIntervalEnd,
|
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. */
|
/** The road heads due north, so the manifest reports normal = tangent + 90 = east. */
|
||||||
const AXIS = 90;
|
const AXIS = 90;
|
||||||
const TANGENT = 0;
|
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 {
|
function interval(side?: Side, boundaryIndex?: number): RoadIntervalAnchor {
|
||||||
return {
|
return {
|
||||||
@@ -47,33 +57,36 @@ function dragBy(azimuth: number, meters: number): [Coordinate, Coordinate] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('road-edge-offset projection', () => {
|
describe('road-edge-offset projection', () => {
|
||||||
it('widens when the left handle is dragged along the axis', () => {
|
it('widens whichever side is dragged away from the centreline', () => {
|
||||||
const handle = makeHandle('road-edge-offset', interval('left'));
|
// The bug this pins: the left handle used to be drawn over the right kerb, so
|
||||||
expect(projectHandleValue(handle, ...dragBy(AXIS, 2))).toBeCloseTo(2, 3);
|
// 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', () => {
|
it('narrows whichever side is dragged toward the centreline', () => {
|
||||||
// The right handle sits at tangent - 90 while the manifest still reports
|
expect(
|
||||||
// tangent + 90 as its axis, so "outward" is the negative axis direction.
|
projectHandleValue(makeHandle('road-edge-offset', interval('left')), ...dragBy(OUTWARD_RIGHT, 2)),
|
||||||
// Both sides must read as a positive offset, or one of them drags inverted.
|
).toBeCloseTo(-2, 3);
|
||||||
const handle = makeHandle('road-edge-offset', interval('right'));
|
expect(
|
||||||
expect(projectHandleValue(handle, ...dragBy(AXIS + 180, 2))).toBeCloseTo(2, 3);
|
projectHandleValue(makeHandle('road-edge-offset', interval('right')), ...dragBy(OUTWARD_LEFT, 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('adds to the value the manifest already reported', () => {
|
it('adds to the value the manifest already reported', () => {
|
||||||
const handle = makeHandle('road-edge-offset', interval('left'), 1.5);
|
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', () => {
|
it('clamps to the manifest range instead of extrapolating', () => {
|
||||||
const handle = makeHandle('road-edge-offset', interval('left'), 0, -1, 1);
|
const handle = makeHandle('road-edge-offset', interval('left'), 0, -1, 1);
|
||||||
expect(projectHandleValue(handle, ...dragBy(AXIS, 9))).toBeCloseTo(1, 6);
|
expect(projectHandleValue(handle, ...dragBy(OUTWARD_LEFT, 9))).toBeCloseTo(1, 6);
|
||||||
expect(projectHandleValue(handle, ...dragBy(AXIS + 180, 9))).toBeCloseTo(-1, 6);
|
expect(projectHandleValue(handle, ...dragBy(OUTWARD_RIGHT, 9))).toBeCloseTo(-1, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores drag perpendicular to the axis', () => {
|
it('ignores drag perpendicular to the axis', () => {
|
||||||
@@ -85,13 +98,13 @@ describe('road-edge-offset projection', () => {
|
|||||||
describe('road-sidewalk-width projection', () => {
|
describe('road-sidewalk-width projection', () => {
|
||||||
it('grows outward and shrinks inward', () => {
|
it('grows outward and shrinks inward', () => {
|
||||||
const handle = makeHandle('road-sidewalk-width', interval('left'), 2, 0, 8);
|
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(OUTWARD_LEFT, 1.5))).toBeCloseTo(3.5, 3);
|
||||||
expect(projectHandleValue(handle, ...dragBy(AXIS + 180, 1.5))).toBeCloseTo(0.5, 3);
|
expect(projectHandleValue(handle, ...dragBy(OUTWARD_RIGHT, 1.5))).toBeCloseTo(0.5, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never produces a negative width', () => {
|
it('never produces a negative width', () => {
|
||||||
const handle = makeHandle('road-sidewalk-width', interval('right'), 2, 0, 8);
|
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(scalar).toBeCloseTo(0, 6);
|
||||||
expect(constraintValueFor(handle, scalar)).toEqual({ widthMeters: 0, transition: 'smoothstep' });
|
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', () => {
|
describe('interval range projection', () => {
|
||||||
const window = { minStation: 0.1, maxStation: 0.9 };
|
const window = { minStation: 0.1, maxStation: 0.9 };
|
||||||
const LENGTH = 1000;
|
const LENGTH = 1000;
|
||||||
@@ -224,4 +283,29 @@ describe('draft constraint', () => {
|
|||||||
const handle = makeHandle('road-edge-offset', interval('left'));
|
const handle = makeHandle('road-edge-offset', interval('left'));
|
||||||
expect(anchorSnapshotFor(handle, centerline, []).tangentAzimuth).toBeCloseTo(0, 6);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,13 +6,21 @@
|
|||||||
// own range, and packaged as a `native-road-edits/v2` value. The client never
|
// own range, and packaged as a `native-road-edits/v2` value. The client never
|
||||||
// writes a coordinate into a road polygon.
|
// 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 {
|
import type {
|
||||||
AnchorSnapshot,
|
AnchorSnapshot,
|
||||||
ConstraintValue,
|
ConstraintValue,
|
||||||
EditHandle,
|
EditHandle,
|
||||||
JunctionReserve,
|
JunctionReserve,
|
||||||
RoadConstraint,
|
RoadConstraint,
|
||||||
|
RoadEditOperation,
|
||||||
RoadIntervalAnchor,
|
RoadIntervalAnchor,
|
||||||
Transition,
|
Transition,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -28,17 +36,21 @@ export interface IntervalWindow {
|
|||||||
/**
|
/**
|
||||||
* Turns "along axisAzimuth" into "outward" for a handle.
|
* Turns "along axisAzimuth" into "outward" for a handle.
|
||||||
*
|
*
|
||||||
* `makeRoadHandles()` places the left handle at `tangent + 90` and the right one
|
* The manifest reports `axisAzimuth = tangent + 90` for both sides, which is the
|
||||||
* at `tangent - 90`, but reports `axisAzimuth = tangent + 90` for both. Since a
|
* geometry compiler's *right*: `offsetLine()` treats a positive offset as
|
||||||
* positive `offsetMeters` / `widthMeters` always widens the road, a right-side
|
* counter-clockwise from the direction of travel, and sidewalks are placed at
|
||||||
* drag measured along that axis has to be negated. Lane dividers are the
|
* `heading + (side === 'left' ? -90 : 90)`. So the left kerb lies along the
|
||||||
* exception: their offset is a signed lateral position already measured along
|
* negative axis, and a left-side drag has to be negated for a positive
|
||||||
* the same axis, so the raw projection is the value.
|
* `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 {
|
function outwardSign(handle: EditHandle): number {
|
||||||
if (handle.kind === 'road-lane-divider') return 1;
|
if (handle.kind === 'road-lane-divider') return 1;
|
||||||
const side = 'side' in handle.anchor ? handle.anchor.side : undefined;
|
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);
|
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`. */
|
/** The manifest hangs `boundaryIndex` on the anchor; the document needs it in `value`. */
|
||||||
function boundaryIndexOf(handle: EditHandle): number {
|
function boundaryIndexOf(handle: EditHandle): number {
|
||||||
const index = 'boundaryIndex' in handle.anchor ? handle.anchor.boundaryIndex : undefined;
|
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 {
|
export interface DraftIdentity {
|
||||||
constraintId: string;
|
constraintId: string;
|
||||||
operationId: string;
|
operationId: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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';
|
import type { ConstraintKind, EditHandle, HandleManifest, SemanticAnchor } from './types';
|
||||||
|
|
||||||
function handle(handleId: string, kind: ConstraintKind, anchor: SemanticAnchor, editable = true): EditHandle {
|
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', () => {
|
describe('disabledReasonOf', () => {
|
||||||
it('says nothing for an editable handle', () => {
|
it('says nothing for an editable handle', () => {
|
||||||
expect(disabledReasonOf(handle('h', 'road-edge-offset', roadInterval('road:way/1:forward')))).toBeUndefined();
|
expect(disabledReasonOf(handle('h', 'road-edge-offset', roadInterval('road:way/1:forward')))).toBeUndefined();
|
||||||
|
|||||||
@@ -4,7 +4,15 @@
|
|||||||
// be unit-tested in node. `handle-layer.ts` is the OL adapter that renders the
|
// be unit-tested in node. `handle-layer.ts` is the OL adapter that renders the
|
||||||
// result.
|
// 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.
|
* 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;
|
if (handle.editable) return undefined;
|
||||||
return handle.disabledReason || '该手柄位于路口保留区,请进入 JunctionTools 编辑。';
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user