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 的区间带还要用。
|
||||
Reference in New Issue
Block a user