diff --git a/.trellis/tasks/08-26-direct-edit-map-editor/research/ol-ext-probe-result.md b/.trellis/tasks/08-26-direct-edit-map-editor/research/ol-ext-probe-result.md new file mode 100644 index 0000000..bd45321 --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-map-editor/research/ol-ext-probe-result.md @@ -0,0 +1,73 @@ +# ol-ext Transform 探针结论 + +`implement.md` 第 1 步的门禁结果。人工验证由 dingkang 在浏览器完成,2026-08-27。 + +## 门禁结果:三条全过 + +| 门禁 | 结果 | 证据 | +| --- | --- | --- | +| OL `Map` 未重建 | 通过 | 实例 #2 / 累计构造 2 次(StrictMode 双挂载),2438 次 React 渲染下未重建 | +| 基线 source 未被写入 | 通过 | 60 次拖拽,写入 0 次,几何指纹未变 | +| proxy 拖拽稳定且事件能转成 draft 值 | 通过 | 1377 个 `translating` 事件;跟手连续、不跳数;松手不回弹;反复几十次不失手、不报错 | + +投影链路同时被验证:钳位范围内 `raw 3.924 / draft 3.924` 完全相等,说明 +`signedMetersAlongAxis()` → `projectHandleValue()` 的换算与钳位都正确。 + +## 但不把 ol-ext 用于道路手柄 + +门禁通过只意味着 ol-ext **可以**用(决策表第一行的「可选依赖」),不意味着它是更好的选择。 +探针同时暴露了决策表第三行的情况,因此按该行处置。 + +### 决定性证据:手柄与约束值脱钩 + +``` +translateend -> raw -24.146 / draft -5.400 +``` + +`Transform` 的 translate 分支按原始 delta 调 `geometry.translate()`,不知道也不关心我们的钳位。 +于是手柄被拖到 -24.1 米处,而约束只到 -5.4 米——手柄停在道路永远不会变成的位置上。 + +生产要求相反:手柄必须贴着钳位边界停下,位置由**约束值反算**而来,而不是跟随光标。 +这意味着位置更新必须由我们自己拥有。用 ol-ext 就得每帧撤销它刚做的 translate; +自己写 `PointerInteraction` 则直接不移动过界。 + +### 其余理由 + +- 我们实际用到的只有「Point proxy 上的 translate + start/move/end 生命周期 + hitTolerance 命中」。 + 这些 `ol/interaction/Translate` 原生就有,且自带一等 TypeScript 类型。 + ol-ext 的增量价值是 bounding box 的 scale / stretch / rotate —— 而道路法线偏移、 + 区间范围、路口 cutback 都用不上它,`canvas-integration.md` 早已指出这点。 +- ol-ext 4.0.38 不带类型,也没有 `@types/ol-ext`。采用它就要长期自己维护一份声明文件。 + 探针期间已经踩到一次:它的自定义事件名不在 OL 的事件类型联合里, + `on` / `un` 无法直接声明在类上,只能另设接口做一次转换。 +- 每个 mousemove 都会走 `handleMoveEvent_ → getFeatureAtPixel_ → forEachFeatureAtPixel`, + 触发 OL 的 canvas 回读(控制台 `Canvas2D getImageData` 警告即来自此)。 + 这不是崩溃原因,但属于固有开销。 + +## 探针期间发现的、与 ol-ext 无关的教训 + +浏览器白屏一度被误当成 ol-ext 的稳定性问题,实际是探针自身的 bug: + +``` +at projectHandleValue (projection.ts:49) +at ProbeMap.tsx:133 +at basicStateReducer → updateReducer → useState +``` + +几何计算被写在了 `setState` 的 updater 函数里。React 会延迟、且在 StrictMode 下重复调用 +updater;真正执行时闭包里的 `start` 已被 `translateend` 置为 `null`,于是 `toLonLat(null)` 抛错。 +`start!` 的非空断言正是把运行时问题藏过类型检查的地方。 + +**教训(适用于第 3、4 步)**:绝不在 `setState` updater 内做几何计算或读取实时 OL 状态。 +先在事件处理器里算出普通值,再传进 updater。 + +另一条:逐个 `translating` 事件更新 React state 会产生上千次渲染。探针用 +`requestAnimationFrame` 合并后才可用。生产要更进一步——ghost 直接写自己的 OL source, +绝不为每次指针移动重渲染 React 树。 + +## 结论 + +按回退方案 1 推进:原生 OL `Snap` + 小型 `PointerInteraction` adapter,位置由约束值反算。 + +`HandleManifest → RoadEditOperation → RoadConstraint → preview solver` 数据合约不变, +第 2 步已交付的 `EditSession` / `projection` / `meters` 全部保留,探针已验证它们可用。 diff --git a/.trellis/tasks/08-26-direct-edit-map-editor/research/reserve-handles-unreachable.md b/.trellis/tasks/08-26-direct-edit-map-editor/research/reserve-handles-unreachable.md new file mode 100644 index 0000000..b8cde4c --- /dev/null +++ b/.trellis/tasks/08-26-direct-edit-map-editor/research/reserve-handles-unreachable.md @@ -0,0 +1,70 @@ +# reserve 内手柄在当前服务端实现下不可达 + +第 3 步人工验证时发现:点遍所有道路都看不到灰色(不可拖)手柄。经实测确认这是**正确行为**,不是漏测。 + +## 实测数据 + +`test/fixtures/fengshu-er-road.osm`,24 条方向道路、32 个 reserve: + +``` +road handles : 126 +road NOT editable : 0 +with disabledReason : 0 +junction handles : 96 (JunctionTools 所有,主地图已过滤) +``` + +## 为什么不可达 + +`makeRoadHandles()` 里 `editable: false` 的唯一来源是 `unavailable`: + +```js +const start = Math.min(1, startReserve); +const end = Math.max(0, 1 - endReserve); +const unavailable = start >= end; +``` + +而 `junctionReserves()` 给每端的保留区比例有硬上限: + +```js +const fraction = Math.min(0.45, cutback / length); +``` + +于是 `startReserve ≤ 0.45`、`endReserve ≤ 0.45`,即 `start ≤ 0.45` 且 `end ≥ 0.55`。 +`start >= end` 结构上永远为假,`unavailable` 分支是死代码。 + +实测输出里能直接看到上限生效的样子——两端都撞到 0.45 的短路段: + +``` +segment:way/858770822/1 window=[0.450,0.550] +segment:way/117947564/0 window=[0.450,0.550] +segment:way/851989492/2 window=[0.450,0.550] +``` + +另外手柄的 station 取自 `(start + end) / 2`,即无保留区窗口的正中, +所以三类道路手柄的位置也永远不会落进 reserve。 + +## 对验收标准的影响 + +prd.md 的「落在 junction reserve 内的手柄不可拖动,并提示进入 JunctionTools」 +对当前三类道路手柄是**空真**:它们永远不落在 reserve 内,所以约束自动成立。 + +客户端一侧的置灰与原因提示路径有单测覆盖(`selection.test.ts`:disabled 手柄被保留、 +`disabledReasonOf` 始终给出原因),只是服务端从不产生这种输入。因此第 3 步不需要改动。 + +reserve 边界真正变得用户可见是在第 4 步的**区间范围手柄**:它可以被拖向 reserve 边界, +由 `projectIntervalEnd()` 钳到 `window.minStation` / `maxStation`。届时这条边界才有可演示的行为。 + +## 留给用户决定的设计问题 + +那个死分支暗示原作者期望「短路段应完全归 JunctionTools」。但 0.45 的上限把结果改成了: +一条几何上被两个大路口主导的短路,仍然会在正中间得到一条仅占全长 10% 的可编辑带 +(上面 `[0.450,0.550]` 那几条)。 + +两种取向都讲得通,需要产品判断: + +- 保持现状:任何道路都留一条可编辑带,哪怕很窄。 +- 改为:当 `cutback / length` 两端之和超过某阈值时,整条路标 `editable: false`, + 引导用户进入 JunctionTools。这会让死分支复活,也让验收标准变成可演示的。 + +改动落在 `src/compile/direct-edit-solver.js`(属已归档的 `direct-edit-solver-api` 任务范围), +不在本任务范围内,故此处只记录不实施。 diff --git a/.trellis/tasks/08-26-direct-edit-map-editor/task.json b/.trellis/tasks/08-26-direct-edit-map-editor/task.json index a953020..ed543ed 100644 --- a/.trellis/tasks/08-26-direct-edit-map-editor/task.json +++ b/.trellis/tasks/08-26-direct-edit-map-editor/task.json @@ -3,7 +3,7 @@ "name": "direct-edit-map-editor", "title": "主地图道路区间编辑器", "description": "ol-ext 限时探针与主地图外缘/步行带/车道分隔/区间范围拖拽", - "status": "planning", + "status": "in_progress", "dev_type": null, "scope": null, "package": null, diff --git a/workbench/client/probe/ol-ext-transform/ProbeMap.tsx b/workbench/client/probe/ol-ext-transform/ProbeMap.tsx new file mode 100644 index 0000000..6343f89 --- /dev/null +++ b/workbench/client/probe/ol-ext-transform/ProbeMap.tsx @@ -0,0 +1,269 @@ +// Timeboxed probe: can ol-ext's Transform carry generic handle hit-testing, +// pointer lifecycle and visual feedback for us? +// +// It is bound to a throwaway proxy Point only. `Transform` translates the +// geometry of whatever feature it is given, so the baseline layer is never +// passed to it — and this page counts every write to that layer to prove it. + +import { useEffect, useRef, useState } from 'react'; +import Feature from 'ol/Feature'; +import Map from 'ol/Map'; +import View from 'ol/View'; +import LineString from 'ol/geom/LineString'; +import Point from 'ol/geom/Point'; +import Polygon from 'ol/geom/Polygon'; +import type SimpleGeometry from 'ol/geom/SimpleGeometry'; +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 Transform, { type TransformEvents } from 'ol-ext/interaction/Transform'; +import { fromLonLat, signedMetersAlongAxis, type Coordinate } from '../../src/edit/meters'; +import { projectHandleValue } from '../../src/edit/projection'; +import { CENTERLINE, laneDividerLines, PROBE_HANDLE, roadSurfaceRing } from './fixture'; + +/** Survives re-renders so a rebuilt Map is impossible to miss. */ +let mapInstances = 0; + +interface Report { + mapInstanceId: number; + baselineWrites: number; + baselineChanged: boolean; + drags: number; + moves: number; + /** Unclamped signed meters along the axis — shows tracking quality. */ + rawMeters: number | null; + /** The clamped value a constraint would actually carry. */ + draftMeters: number | null; + log: string[]; +} + +const EMPTY: Report = { + mapInstanceId: 0, + baselineWrites: 0, + baselineChanged: false, + drags: 0, + moves: 0, + rawMeters: null, + draftMeters: null, + log: [], +}; + +const project = (point: Coordinate) => fromLonLat(point); +const fingerprint = (source: VectorSource) => + JSON.stringify(source.getFeatures().map((feature) => (feature.getGeometry() as SimpleGeometry).getCoordinates())); + +export function ProbeMap() { + const target = useRef(null); + const renders = useRef(0); + const [report, setReport] = useState(EMPTY); + const [nudge, setNudge] = useState(0); + renders.current += 1; + + useEffect(() => { + if (!target.current) return; + + // Baseline: read-only compiler output. Nothing below may write to it. + const baseline = new VectorSource({ + features: [ + new Feature(new Polygon([roadSurfaceRing().map(project)])), + ...laneDividerLines().map((line) => new Feature(new LineString(line.map(project)))), + new Feature(new LineString(CENTERLINE.map(project))), + ], + }); + const baselineAtStart = fingerprint(baseline); + let writes = 0; + const onWrite = () => { + writes += 1; + // Read the source now, not inside the updater: React may run an updater + // later and more than once, which would sample a different moment. + const changed = fingerprint(baseline) !== baselineAtStart; + setReport((current) => ({ ...current, baselineWrites: writes, baselineChanged: changed })); + }; + for (const type of ['addfeature', 'changefeature', 'removefeature'] as const) baseline.on(type, onWrite); + + // Proxy: a single throwaway handle feature, the only thing Transform sees. + const proxyFeature = new Feature(new Point(project(PROBE_HANDLE.position))); + const proxyLayer = new VectorLayer({ + source: new VectorSource({ features: [proxyFeature] }), + style: new Style({ + image: new CircleStyle({ + radius: 8, + fill: new Fill({ color: '#00a5cf' }), + stroke: new Stroke({ color: '#fff', width: 2 }), + }), + }), + }); + + const map = new Map({ + target: target.current, + layers: [ + new VectorLayer({ + source: baseline, + style: new Style({ fill: new Fill({ color: '#3f4b5088' }), stroke: new Stroke({ color: '#296956' }) }), + }), + proxyLayer, + ], + // Zoom 20: the ±5.4 m clamp spans ~47 px here, so tracking is observable. + // At zoom 18 it was ~12 px and every drag saturated instantly. + view: new View({ center: project(PROBE_HANDLE.position), zoom: 20 }), + }); + mapInstances += 1; + const instance = mapInstances; + + const transform = new Transform({ + layers: [proxyLayer], + filter: (feature) => feature === proxyFeature, + hitTolerance: 12, + translate: true, + translateFeature: true, + scale: false, + rotate: false, + stretch: false, + }); + map.addInteraction(transform); + + let start: Coordinate | null = null; + let drags = 0; + let moves = 0; + let frame = 0; + let pending: { raw: number; draft: number } | null = null; + const note = (line: string) => setReport((current) => ({ ...current, log: [line, ...current.log].slice(0, 12) })); + const coordinates = () => (proxyFeature.getGeometry() as Point).getCoordinates() as Coordinate; + // PROBE_HANDLE is a left-side handle, so "outward" is the positive axis + // direction and the raw projection needs no sign flip. + const rawMeters = (from: Coordinate, to: Coordinate) => + PROBE_HANDLE.value.current + signedMetersAlongAxis(from, to, PROBE_HANDLE.axisAzimuth); + + // Per-pointermove React state updates were the previous run's 1656 renders. + // Coalescing to one frame keeps the readout honest without making React the + // bottleneck. Production must go further: the ghost writes to its own OL + // source directly and never re-renders the tree per pointer move. + const flush = () => { + frame = 0; + if (!pending) return; + const { raw, draft } = pending; + setReport((current) => ({ ...current, moves, rawMeters: raw, draftMeters: draft })); + }; + + const onStart = () => { + start = [...coordinates()] as Coordinate; + note('translatestart'); + }; + const onMove = () => { + if (!start) return; + moves += 1; + const now = coordinates(); + pending = { raw: rawMeters(start, now), draft: projectHandleValue(PROBE_HANDLE, start, now) }; + if (!frame) frame = requestAnimationFrame(flush); + }; + const onEnd = () => { + drags += 1; + const now = coordinates(); + const raw = start ? rawMeters(start, now) : null; + const draft = start ? projectHandleValue(PROBE_HANDLE, start, now) : null; + setReport((current) => ({ ...current, drags, moves, rawMeters: raw, draftMeters: draft })); + note(`translateend -> raw ${raw?.toFixed(3) ?? 'n/a'} / draft ${draft?.toFixed(3) ?? 'n/a'}`); + start = null; + }; + // ol-ext's event names live outside OpenLayers' typed event unions; narrow + // once here rather than casting at every listener. + const events = transform as unknown as TransformEvents; + const onSelect = () => note('select'); + events.on('translatestart', onStart); + events.on('translating', onMove); + events.on('translateend', onEnd); + events.on('select', onSelect); + + setReport({ ...EMPTY, mapInstanceId: instance }); + + return () => { + if (frame) cancelAnimationFrame(frame); + events.un('translatestart', onStart); + events.un('translating', onMove); + events.un('translateend', onEnd); + events.un('select', onSelect); + for (const type of ['addfeature', 'changefeature', 'removefeature'] as const) baseline.un(type, onWrite); + map.setTarget(undefined); + }; + // Built once per mount, exactly like the production MapCanvas. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const pass = (ok: boolean) => (ok ? '通过' : '未通过'); + const saturated = report.draftMeters !== null && Math.abs(report.draftMeters) >= PROBE_HANDLE.value.max - 0.001; + return ( +
+
+ +
+ ); +} diff --git a/workbench/client/probe/ol-ext-transform/README.md b/workbench/client/probe/ol-ext-transform/README.md new file mode 100644 index 0000000..6b1a7cc --- /dev/null +++ b/workbench/client/probe/ol-ext-transform/README.md @@ -0,0 +1,68 @@ +# ol-ext Transform 限时探针 + +对应 `.trellis/tasks/08-26-direct-edit-map-editor/implement.md` 第 1 步。 + +这个探针只回答一个问题:**能否复用 ol-ext 通用 handle 的命中、pointer 生命周期与视觉反馈。** +它不回答“道路语义怎么建模”——那部分由 `src/edit/` 的 `EditSession` / `projection` 负责,且已经独立单测通过。 + +## 怎么跑 + +```bash +npm install ol-ext --no-save # 门禁通过前不进依赖清单 +npm run dev +``` + +然后打开 。 + +不需要导入 OSM,也不需要后端:页面用 `fixture.ts` 里的合成道路。 + +## 你要做的事 + +拖动地图上那个**蓝色圆点手柄**,来回拖几次,快慢都试,然后看右侧面板。 + +## 三条门禁(必须同时成立) + +| # | 门禁 | 面板怎么读 | +|---|---|---| +| 1 | OL `Map` 未被重建 | “实例 #N,累计构造 N 次”两个数字必须相等。再点几次“强制 React 重渲染”,数字仍要相等。 | +| 2 | 基线 source 未被写入 | “写入 0 次,几何未改变”。拖拽过程中这行**任何变化都算失败**。 | +| 3 | proxy 拖拽稳定且事件能转成 draft 值 | 看**「原始位移」**那行跟手连续变化、松手不跳;手柄不粘滞、不丢命中。 | + +门禁 3 要看的是「原始位移」,不是「约束值」。约束值带生产钳位 `±5.4 米`, +而 5.4 米在 zoom 20 只有约 47 像素,稍微拖远就会顶到边界显示成常量——那是正常的,不代表跟踪有问题。 +「原始位移」不钳位,所以它才反映跟手质量。 + +两个数都是用生产代码算的(`signedMetersAlongAxis()` / `projectHandleValue()`), +所以门禁 3 同时验证了“事件 → 约束值”这条链路。 + +另外面板会显示收到了多少个 `translating` 事件。这个数字和 React 渲染次数的比例是个有用的信号: +探针已经把逐帧读数用 `requestAnimationFrame` 合并了,生产实现还要更进一步—— +ghost 直接写自己的 OL source,绝不为每次指针移动重渲染 React 树。 + +注意:**卸载/重新挂载地图会构造新的 Map,这是预期行为**,不算门禁 1 失败。门禁 1 针对的是拖拽与重渲染。 + +## 请回报给我 + +1. 三条门禁分别通过/未通过。 +2. 未通过的,面板上的具体数字或现象。 +3. 主观手感:拖拽是否顺滑,手柄命中是否可靠。 + +## 已知的、探针之外的结论 + +这些我已经查过,不用你验证,但会计入最终取舍: + +- ol-ext 4.0.38,BSD-3-Clause,`peerDependencies: ol >= 5.3.0`,无运行时依赖。 +- **不带 TypeScript 类型,也没有 `@types/ol-ext`。** 本目录的 `ol-ext.d.ts` 是我为探针手写的最小声明; + 真要把 ol-ext 提为正式依赖,就要长期自己维护一份声明文件。 +- `Transform` 的 `translate` 分支直接对传入 feature 调 `geometry.translate()`。 + 所以它只能绑一次性 proxy,绝不能绑编译产物图层——这也是门禁 2 存在的原因。 +- 它的手柄模型是 bounding box 的 scale/rotate/stretch,跟道路法线偏移、区间范围、路口 cutback 不是一回事。 + 即使门禁全过,它最多承担“通用手柄的命中与拖拽生命周期”,语义投影仍然是我们自己的 `projection.ts`。 + +## 如果门禁没过 + +按 `design.md` 的回退优先级,删掉本目录,改用回退方案 1: +原生 OL `Snap` + 小型 `PointerInteraction` adapter。 + +`HandleManifest → RoadEditOperation → RoadConstraint → preview solver` 的数据合约不变, +所以这一步失败只换输入层,第 2 步已交付的 `EditSession` / `projection` / `meters` 全部保留。 diff --git a/workbench/client/probe/ol-ext-transform/fixture.ts b/workbench/client/probe/ol-ext-transform/fixture.ts new file mode 100644 index 0000000..9dde3c7 --- /dev/null +++ b/workbench/client/probe/ol-ext-transform/fixture.ts @@ -0,0 +1,61 @@ +// Synthetic road for the probe, so the page runs without importing OSM or +// touching the workbench API. Geometry helpers come from the real edit modules +// on purpose: the probe has to exercise the production conversion, not a copy. + +import { offsetCoordinate, polylineLengthMeters, type Coordinate } from '../../src/edit/meters'; +import type { EditHandle } from '../../src/edit/types'; + +/** The road heads due north, so its normal — the drag axis — points due east. */ +export const TANGENT_AZIMUTH = 0; +export const AXIS_AZIMUTH = 90; +export const WIDTH_METERS = 12; +export const LANE_COUNT = 4; + +export const CENTERLINE: Coordinate[] = [ + [116.397, 39.905], + [116.397, 39.915], +]; + +export const ROAD_LENGTH_METERS = polylineLengthMeters(CENTERLINE); + +const MIDPOINT: Coordinate = [116.397, 39.91]; + +/** Outer ring of the baseline road surface, EPSG:4326. */ +export function roadSurfaceRing(): Coordinate[] { + const half = WIDTH_METERS / 2; + const left = CENTERLINE.map((point) => offsetCoordinate(point, TANGENT_AZIMUTH + 90, half)); + const right = CENTERLINE.map((point) => offsetCoordinate(point, TANGENT_AZIMUTH - 90, half)); + return [...left, ...right.reverse(), left[0]]; +} + +/** Lane divider lines, to make it obvious if the baseline ever gets rewritten. */ +export function laneDividerLines(): Coordinate[][] { + const laneWidth = WIDTH_METERS / LANE_COUNT; + const lines: Coordinate[][] = []; + for (let index = 1; index < LANE_COUNT; index += 1) { + const lateral = -WIDTH_METERS / 2 + laneWidth * index; + lines.push(CENTERLINE.map((point) => offsetCoordinate(point, TANGENT_AZIMUTH + 90, lateral))); + } + return lines; +} + +/** + * A left edge-offset handle shaped exactly like the one `makeRoadHandles()` + * emits, so `projectHandleValue()` sees production input. + */ +export const PROBE_HANDLE: EditHandle = { + handleId: 'probe:road-edge-offset:left', + kind: 'road-edge-offset', + anchor: { + type: 'road-interval', + roadId: 'probe:road/1:forward', + startStation: 0.15, + endStation: 0.85, + side: 'left', + }, + position: offsetCoordinate(MIDPOINT, AXIS_AZIMUTH, WIDTH_METERS / 2), + axisAzimuth: AXIS_AZIMUTH, + value: { current: 0, min: -WIDTH_METERS * 0.45, max: WIDTH_METERS * 0.45, unit: 'meter' }, + affects: [], + editable: true, +}; diff --git a/workbench/client/probe/ol-ext-transform/index.html b/workbench/client/probe/ol-ext-transform/index.html new file mode 100644 index 0000000..123d5cf --- /dev/null +++ b/workbench/client/probe/ol-ext-transform/index.html @@ -0,0 +1,12 @@ + + + + + + ol-ext Transform 限时探针 + + +
+ + + diff --git a/workbench/client/probe/ol-ext-transform/main.tsx b/workbench/client/probe/ol-ext-transform/main.tsx new file mode 100644 index 0000000..2dcc24c --- /dev/null +++ b/workbench/client/probe/ol-ext-transform/main.tsx @@ -0,0 +1,61 @@ +import { Component, StrictMode, useState, type ReactNode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { ProbeMap } from './ProbeMap'; +import './probe.css'; + +// A probe that white-screens teaches nothing: the run has to say *whose* bug it +// was, ol-ext's or ours. This boundary keeps the error on the page so the gate +// result stays attributable. +class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { + state: { error: Error | null } = { error: null }; + + static getDerivedStateFromError(error: Error) { + return { error }; + } + + render() { + const { error } = this.state; + if (!error) return this.props.children; + return ( +
+

探针崩溃了

+

请把下面整段发给我 —— 它决定这次失败算谁的。

+
+          {error.name}: {error.message}
+          {'\n\n'}
+          {error.stack}
+        
+ +
+ ); + } +} + +// The mount toggle exists so the React lifecycle question gets answered too: a +// remount is *expected* to build a new Map, while dragging and re-rendering must +// not. Gate 1 is about the latter. +function Probe() { + const [mounted, setMounted] = useState(true); + return ( + <> + + {mounted ? :

地图已卸载。重新挂载会构造新的 Map,这是预期行为。

} + + ); +} + +createRoot(document.getElementById('probe')!).render( + + + + + , +); diff --git a/workbench/client/probe/ol-ext-transform/ol-ext.d.ts b/workbench/client/probe/ol-ext-transform/ol-ext.d.ts new file mode 100644 index 0000000..dfd3483 --- /dev/null +++ b/workbench/client/probe/ol-ext-transform/ol-ext.d.ts @@ -0,0 +1,57 @@ +// ol-ext 4.0.38 ships no TypeScript declarations and there is no @types/ol-ext. +// This local declaration covers only what the probe touches. It deliberately +// lives inside the probe directory: if the gate fails, deleting the directory +// removes the typing debt with it. Promoting ol-ext to a real dependency would +// also mean owning a real declaration file. + +declare module 'ol-ext/interaction/Transform' { + import type Feature from 'ol/Feature'; + import type Collection from 'ol/Collection'; + import type BaseLayer from 'ol/layer/Base'; + import PointerInteraction from 'ol/interaction/Pointer'; + + interface TransformOptions { + layers?: BaseLayer[] | BaseLayer; + features?: Collection; + filter?: (feature: Feature, layer: BaseLayer) => boolean; + hitTolerance?: number; + translate?: boolean; + translateFeature?: boolean; + translateBBox?: boolean; + stretch?: boolean; + scale?: boolean; + rotate?: boolean; + selection?: boolean; + pointRadius?: number | number[] | ((feature: Feature) => number | number[]); + style?: unknown; + } + + /** Events: select, translatestart | translating | translateend, scale*, rotate*. */ + export interface TransformEvent { + type: string; + feature?: Feature; + features?: Collection; + /** `translating` only: [deltaX, deltaY] in map units (EPSG:3857 here). */ + delta?: [number, number]; + coordinate?: [number, number]; + pixel?: [number, number]; + /** `*end` only. */ + transformed?: boolean; + } + + /** + * ol-ext's custom event names are not in OpenLayers' event-type unions, so + * overriding `on` / `un` on the class would clash with `Interaction` and break + * `map.addInteraction()`. Consumers narrow through this interface instead — + * one documented conversion rather than a cast at every listener. + */ + export interface TransformEvents { + on(type: string, listener: (event: TransformEvent) => void): void; + un(type: string, listener: (event: TransformEvent) => void): void; + } + + export default class Transform extends PointerInteraction { + constructor(options?: TransformOptions); + select(feature: Feature | null, add?: boolean): void; + } +} diff --git a/workbench/client/probe/ol-ext-transform/probe.css b/workbench/client/probe/ol-ext-transform/probe.css new file mode 100644 index 0000000..aac1e3b --- /dev/null +++ b/workbench/client/probe/ol-ext-transform/probe.css @@ -0,0 +1,148 @@ +/* Probe-only styles. Deliberately not sharing the workbench stylesheet, so the + whole probe can be deleted in one directory. */ + +* { + box-sizing: border-box; +} +body { + margin: 0; + font: + 14px/1.5 system-ui, + -apple-system, + 'Segoe UI', + sans-serif; + color: #1b2426; + background: #f2f4f3; +} +.probe-nav { + display: flex; + gap: 16px; + align-items: center; + padding: 10px 16px; + background: #1b2426; + color: #f2f4f3; +} +.probe-nav span { + color: #9db0b3; + font-size: 13px; +} +.probe-nav button { + margin-left: auto; +} +button { + padding: 6px 12px; + border: 1px solid #4a5a5d; + border-radius: 6px; + background: #f2f4f3; + cursor: pointer; + font: inherit; +} +.probe { + display: grid; + grid-template-columns: 1fr 340px; + height: calc(100vh - 45px); +} +.probe-map { + height: 100%; + background: #dfe4e2; +} +.probe-panel { + padding: 16px; + overflow-y: auto; + background: #fff; + border-left: 1px solid #d3dad8; +} +.probe-panel h1 { + margin: 0 0 4px; + font-size: 16px; +} +.probe-panel p { + margin: 0 0 12px; + color: #59696c; +} +.probe-panel dt { + margin-top: 12px; + font-weight: 600; +} +.probe-panel dd { + margin: 4px 0 0; + color: #35474a; +} +.probe-panel ul { + margin: 16px 0 0; + padding-left: 18px; + color: #59696c; + font-variant-numeric: tabular-nums; +} +.probe-panel h2 { + margin: 0 0 6px; + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #59696c; +} +.probe-panel section { + margin-bottom: 20px; +} +.probe-panel ol { + margin: 0 0 8px; + padding-left: 20px; +} +.probe-panel ol li { + margin-bottom: 4px; +} +.probe-howto { + padding: 12px; + background: #eef4f2; + border-left: 3px solid #296956; + border-radius: 4px; +} +.probe-howto p, +.probe-ask p { + margin: 8px 0 0; + font-size: 13px; +} +.probe-live { + padding: 12px; + background: #1b2426; + border-radius: 6px; +} +.probe-live h2, +.probe-live p { + color: #9db0b3; +} +.probe-big { + display: block; + margin: 2px 0 6px; + font-size: 30px; + font-variant-numeric: tabular-nums; + color: #4ad2f0; +} +.probe-ask { + padding: 12px; + background: #fdf6e8; + border-left: 3px solid #d49318; + border-radius: 4px; +} +.probe-empty { + padding: 24px; +} +.probe-error { + padding: 24px; + max-width: 900px; +} +.probe-error h1 { + margin: 0 0 4px; + font-size: 18px; + color: #bf3b2e; +} +.probe-error pre { + padding: 12px; + overflow-x: auto; + background: #1b2426; + color: #f2f4f3; + border-radius: 6px; + font-size: 12px; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/workbench/client/probe/ol-ext-transform/tsconfig.json b/workbench/client/probe/ol-ext-transform/tsconfig.json new file mode 100644 index 0000000..07e13df --- /dev/null +++ b/workbench/client/probe/ol-ext-transform/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": [".", "../../src/edit"] +} diff --git a/workbench/client/src/App.tsx b/workbench/client/src/App.tsx index 1e7d036..3105706 100644 --- a/workbench/client/src/App.tsx +++ b/workbench/client/src/App.tsx @@ -1,7 +1,10 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Download, RefreshCw, Save, Upload } from 'lucide-react'; import { api } from './lib/api'; import { MapCanvas } from './components/MapCanvas'; +import { directEditEnabled } from './edit/flag'; +import { disabledReasonOf, handlesForSegment } from './edit/selection'; +import type { HandleManifest } from './edit/types'; import { Button } from './ui/button'; import type { GeoFeature, Override, Road, WorkbenchState } from './types/state'; import type { LayerName } from './map/layers'; @@ -89,6 +92,30 @@ function App() { }) .catch((error: Error) => setStatus(error.message)); }, []); + const [manifest, setManifest] = useState(null); + useEffect(() => { + // Flag off: no manifest request at all, so the network trace matches main. + if (!directEditEnabled) return; + api + .editState() + .then((value) => { + if ('handles' in value) setManifest(value.handles); + }) + // A manifest failure must not take the workbench down with it; the map just + // shows no handles. + .catch((error: Error) => setStatus(`直接编辑手柄不可用:${error.message}`)); + }, []); + const editHandles = useMemo(() => { + if (!directEditEnabled || !state) return []; + // Handle anchors carry a directional road id while reserves are keyed by + // segment, so resolve through the compiled model rather than parsing ids. + const segmentOf = (roadId: string) => state.compiled.model.roads.find((road) => road.id === roadId)?.segmentId; + return handlesForSegment(manifest, selected?.segmentId ?? null, segmentOf); + }, [manifest, selected, state]); + // A greyed handle without a reason reads as a bug rather than an ownership + // boundary, so the first blocked handle explains itself in the header. + const blockedHandle = editHandles.find((handle) => !handle.editable); + const blockedReason = blockedHandle ? disabledReasonOf(blockedHandle) : undefined; const stage = (change: Override) => setStaged((current) => [...current.filter((item) => item.id !== change.id), change]); const save = async () => { @@ -152,6 +179,16 @@ function App() { {staged.length ? `未保存修改 ${staged.length} 项` : '所有修改已保存'} + {directEditEnabled ? ( + + {editHandles.length + ? `手柄 ${editHandles.length} 个` + : selected + ? '当前道路无可编辑手柄' + : '选中道路以显示手柄'} + {blockedReason ? `|${blockedReason}` : ''} + + ) : null} @@ -213,6 +250,7 @@ function App() { scene={scene} onSelectRoad={setSelected} onFeature={handleFeature} + handles={editHandles} /> void; onFeature: (properties: Record) => void; + /** Already filtered to the selected road. Ignored unless `directEdit` is on. */ + handles?: EditHandle[]; } -export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFeature }: Props) { +export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFeature, handles }: Props) { const target = useRef(null); const mapRef = useRef(null); const layersRef = useRef | null>(null); + const handleLayerRef = useRef(null); const selectedRef = useRef(selected); const stateRef = useRef(state); const sceneRef = useRef(scene); @@ -31,15 +37,21 @@ export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFea () => sceneRef.current, ); layersRef.current = layers; + // With `directEdit` off nothing below exists: no extra source, no layer, no + // interaction — the canvas is byte-for-byte the shipped behaviour. + const handleLayer = directEditEnabled ? new EditHandleLayer() : null; + handleLayerRef.current = handleLayer; const map = new Map({ target: target.current, - layers: Object.values(layers), + layers: handleLayer ? [...Object.values(layers), handleLayer.layer] : Object.values(layers), view: new View({ center: [0, 0], zoom: 2 }), }); mapRef.current = map; const select = new Select({ condition: click, - layers: (layer) => layer !== layers.selectedRoad, + // Handles are not selectable features: clicking one must not re-target the + // road inspector, and dragging is owned by the edit interaction. + layers: (layer) => layer !== layers.selectedRoad && layer !== handleLayer?.layer, hitTolerance: 12, style: null, }); @@ -79,6 +91,11 @@ export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFea updateSelectedRoad(layers, selected); layers.osm.changed(); }, [selected]); + useEffect(() => { + // Null when the flag is off, so this whole path is inert on main. + // Only the editHandles source is replaced; baseline layers are never touched. + handleLayerRef.current?.render(handles ?? []); + }, [handles]); useEffect(() => { const layers = layersRef.current; if (!layers) return; diff --git a/workbench/client/src/edit/flag.ts b/workbench/client/src/edit/flag.ts new file mode 100644 index 0000000..975e17b --- /dev/null +++ b/workbench/client/src/edit/flag.ts @@ -0,0 +1,31 @@ +// The `directEdit` kill switch. +// +// Default off, per design.md's rollout shape: with it off the workbench must +// behave exactly like main — no edit interaction registered, no manifest +// request, and none of the three edit sources created. Callers check this once +// and skip the whole subsystem, rather than guarding individual call sites. +// +// Opt in per browser session with `?directEdit=1`, or persistently with +// `localStorage.setItem('directEdit', '1')`. A query parameter of `0` wins over +// stored state so a tab can always be forced back to the shipped behaviour. + +const STORAGE_KEY = 'directEdit'; + +function readFlag(): boolean { + if (typeof window === 'undefined') return false; + const parameter = new URLSearchParams(window.location.search).get(STORAGE_KEY); + if (parameter === '1' || parameter === 'true') return true; + if (parameter === '0' || parameter === 'false') return false; + try { + return window.localStorage.getItem(STORAGE_KEY) === '1'; + } catch { + // Private mode can throw on storage access; treat it as "off". + return false; + } +} + +/** + * Resolved once at module load so a single render pass cannot see the flag flip + * halfway through and build a half-wired map. + */ +export const directEditEnabled = readFlag(); diff --git a/workbench/client/src/edit/handle-layer.ts b/workbench/client/src/edit/handle-layer.ts new file mode 100644 index 0000000..6f44f04 --- /dev/null +++ b/workbench/client/src/edit/handle-layer.ts @@ -0,0 +1,105 @@ +// The `editHandles` source: one Point per draggable handle on the selected road. +// +// Handle features carry only `handleId`. Everything else — kind, anchor, axis, +// range, why a handle is disabled — is looked up in the manifest, so the client +// never keeps a second copy of the constraint model. This class owns both the +// source and that lookup index, which is why they cannot drift apart. +// +// Baseline layers are untouched. This source is additive and is only created +// when the `directEdit` flag is on. + +import Feature from 'ol/Feature'; +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 { fromLonLat } from './meters'; +import { disabledReasonOf } from './selection'; +import type { EditHandle } from './types'; + +/** The only property a handle feature carries. */ +export const HANDLE_ID = 'handleId'; + +const EDITABLE_STYLE: Record = { + 'road-edge-offset': handleStyle('#00a5cf'), + 'road-sidewalk-width': handleStyle('#d49318'), + 'road-lane-divider': handleStyle('#8f6fd0'), +}; + +/** Reserve handles stay visible so the boundary is explainable, but read as inert. */ +const DISABLED_STYLE = new Style({ + image: new CircleStyle({ + radius: 5, + fill: new Fill({ color: '#96a3a6' }), + stroke: new Stroke({ color: '#ffffffaa', width: 1.5 }), + }), +}); + +function handleStyle(color: string): Style { + return new Style({ + image: new CircleStyle({ + radius: 7, + fill: new Fill({ color }), + stroke: new Stroke({ color: '#fff', width: 2 }), + }), + }); +} + +export class EditHandleLayer { + readonly layer: VectorLayer; + private readonly source = new VectorSource(); + private index = new Map(); + + constructor() { + this.layer = new VectorLayer({ + source: this.source, + // Above every baseline layer, so a handle is never hidden under a surface. + zIndex: 100, + style: (feature) => { + const handle = this.handle(String(feature.get(HANDLE_ID))); + if (!handle) return undefined; + return handle.editable ? EDITABLE_STYLE[handle.kind] : DISABLED_STYLE; + }, + }); + } + + /** Replaces the rendered handles. Only this source is touched. */ + render(handles: EditHandle[]): void { + this.index = new Map(handles.map((handle) => [handle.handleId, handle])); + this.source.clear(); + this.source.addFeatures( + handles.map( + (handle) => + new Feature({ + geometry: new Point(fromLonLat(handle.position)), + [HANDLE_ID]: handle.handleId, + }), + ), + ); + } + + clear(): void { + this.index = new Map(); + this.source.clear(); + } + + /** Manifest lookup — the single path from a rendered feature back to semantics. */ + handle(handleId: string): EditHandle | undefined { + return this.index.get(handleId); + } + + /** Only editable handles may start a drag; the rest explain themselves instead. */ + draggable(handleId: string): EditHandle | undefined { + const handle = this.index.get(handleId); + return handle?.editable ? handle : undefined; + } + + /** Why a handle refuses to move, for the status line. */ + disabledReason(handleId: string): string | undefined { + const handle = this.index.get(handleId); + return handle ? disabledReasonOf(handle) : undefined; + } +} diff --git a/workbench/client/src/edit/meters.test.ts b/workbench/client/src/edit/meters.test.ts new file mode 100644 index 0000000..9e90bf8 --- /dev/null +++ b/workbench/client/src/edit/meters.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { + fromLonLat, + haversineMeters, + mercatorUnitsForMeters, + offsetCoordinate, + polylineLengthMeters, + signedMetersAlongAxis, + toLonLat, + type Coordinate, +} from './meters'; + +/** A drag of exactly `meters` along `azimuth` starting from an EPSG:4326 point. */ +function drag(origin: Coordinate, azimuth: number, meters: number): [Coordinate, Coordinate] { + return [fromLonLat(origin), fromLonLat(offsetCoordinate(origin, azimuth, meters))]; +} + +describe('projection round trip', () => { + it('recovers a coordinate through 3857 and back', () => { + for (const point of [ + [0, 0], + [116.397, 39.908], + [-122.42, 37.77], + [18.06, 59.33], + ] as Coordinate[]) { + const [lon, lat] = toLonLat(fromLonLat(point)); + expect(lon).toBeCloseTo(point[0], 9); + expect(lat).toBeCloseTo(point[1], 9); + } + }); +}); + +describe('meter conversion across latitudes', () => { + // EPSG:3857 inflates by 1/cos(latitude). The same projected delta is therefore + // a different number of real meters depending on where you are, and the + // conversion has to reflect that or the geometry stops tracking the cursor. + const MERCATOR_DELTA = 100; + const eastward = (latitude: number) => { + const from = fromLonLat([0, latitude]); + const to: Coordinate = [from[0] + MERCATOR_DELTA, from[1]]; + return signedMetersAlongAxis(from, to, 90); + }; + + it('scales with the cosine of the latitude', () => { + const equator = eastward(0); + const high = eastward(60); + expect(equator).toBeCloseTo(99.889, 2); + expect(high).toBeCloseTo(49.944, 2); + expect(high / equator).toBeCloseTo(Math.cos((60 * Math.PI) / 180), 5); + }); + + it('does not treat a 3857 delta as meters', () => { + // The bug this guards: at 60°N the naive reading is 2x the real distance, + // so a dragged edge would jump twice as far as the pointer moved. + expect(eastward(60)).not.toBeCloseTo(MERCATOR_DELTA, 0); + expect(Math.abs(eastward(60) - MERCATOR_DELTA)).toBeGreaterThan(40); + }); + + it('keeps the same on-screen drag producing the same on-screen change', () => { + // The invariant that actually matters: meters -> map units is the exact + // inverse, so at any latitude the drawn geometry lands under the cursor. + for (const latitude of [0, 23.5, 39.9, 60, 71]) { + const meters = eastward(latitude); + expect(mercatorUnitsForMeters(meters, latitude)).toBeCloseTo(MERCATOR_DELTA, 6); + } + }); +}); + +describe('signed axis projection', () => { + it('is positive along the axis and negative against it', () => { + const [from, to] = drag([0, 0], 90, 5); + expect(signedMetersAlongAxis(from, to, 90)).toBeCloseTo(5, 3); + expect(signedMetersAlongAxis(from, to, 270)).toBeCloseTo(-5, 3); + }); + + it('ignores displacement perpendicular to the axis', () => { + const [from, to] = drag([0, 0], 90, 5); + expect(signedMetersAlongAxis(from, to, 0)).toBeCloseTo(0, 6); + }); + + it('projects an oblique drag onto the axis', () => { + const [from, to] = drag([0, 0], 45, 10); + expect(signedMetersAlongAxis(from, to, 90)).toBeCloseTo(10 * Math.SQRT1_2, 2); + expect(signedMetersAlongAxis(from, to, 45)).toBeCloseTo(10, 2); + }); + + it('measures the same drag identically at high latitude', () => { + // Same physical 5 m, far from the equator: the projected numbers differ but + // the measured meters must not. + const [from, to] = drag([18.06, 59.33], 90, 5); + expect(signedMetersAlongAxis(from, to, 90)).toBeCloseTo(5, 3); + }); +}); + +describe('spherical helpers', () => { + it('matches a known great-circle distance', () => { + expect(haversineMeters([0, 0], [0, 1])).toBeCloseTo(111195, 0); + }); + + it('offsets by exactly the requested distance', () => { + for (const azimuth of [0, 45, 90, 180, 315]) { + const moved = offsetCoordinate([116.397, 39.908], azimuth, 25); + expect(haversineMeters([116.397, 39.908], moved)).toBeCloseTo(25, 6); + } + }); + + it('sums polyline segments', () => { + const line: Coordinate[] = [ + [0, 0], + [0, 0.001], + [0, 0.002], + ]; + expect(polylineLengthMeters(line)).toBeCloseTo(haversineMeters([0, 0], [0, 0.002]), 3); + }); +}); diff --git a/workbench/client/src/edit/meters.ts b/workbench/client/src/edit/meters.ts new file mode 100644 index 0000000..792e451 --- /dev/null +++ b/workbench/client/src/edit/meters.ts @@ -0,0 +1,112 @@ +// Pointer displacement -> meters, and meters -> map units for ghost drawing. +// +// The map renders in EPSG:3857, which inflates distances by 1/cos(latitude). +// Subtracting two 3857 coordinates and calling the result "meters" therefore +// desynchronises the pointer from the geometry: at 60°N a 100-unit drag is only +// ~50 real meters, so the edge would jump twice as far as the cursor moved. +// Every conversion here goes back to EPSG:4326 first and then measures on the +// sphere, matching `haversineMeters()` in src/geometry/lane-geometry.js so a +// client draft and the server solver agree on what a meter is. +// +// This module is the only client-side owner of that conversion; nothing else may +// reimplement it. + +/** Web Mercator semi-major axis — the radius EPSG:3857 projects with. */ +const MERCATOR_RADIUS_METERS = 6378137; +/** Mean earth radius, identical to src/geometry/lane-geometry.js. */ +const EARTH_RADIUS_METERS = 6371008.8; + +export type Coordinate = [number, number]; + +const toRadians = (degrees: number) => (degrees * Math.PI) / 180; +const toDegrees = (radians: number) => (radians * 180) / Math.PI; + +/** EPSG:3857 -> EPSG:4326. Mirrors `toLonLat()` without importing ol. */ +export function toLonLat([x, y]: Coordinate): Coordinate { + return [ + toDegrees(x / MERCATOR_RADIUS_METERS), + toDegrees(2 * Math.atan(Math.exp(y / MERCATOR_RADIUS_METERS)) - Math.PI / 2), + ]; +} + +/** EPSG:4326 -> EPSG:3857. Mirrors `fromLonLat()` without importing ol. */ +export function fromLonLat([lon, lat]: Coordinate): Coordinate { + const clamped = Math.max(-89.999999, Math.min(89.999999, lat)); + return [ + toRadians(lon) * MERCATOR_RADIUS_METERS, + Math.log(Math.tan(Math.PI / 4 + toRadians(clamped) / 2)) * MERCATOR_RADIUS_METERS, + ]; +} + +/** Great-circle distance in meters between two EPSG:4326 coordinates. */ +export function haversineMeters(a: Coordinate, b: Coordinate): number { + const lat1 = toRadians(a[1]); + const lat2 = toRadians(b[1]); + const dLat = toRadians(b[1] - a[1]); + const dLon = toRadians(b[0] - a[0]); + const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + return 2 * EARTH_RADIUS_METERS * Math.asin(Math.min(1, Math.sqrt(h))); +} + +/** Running length of an EPSG:4326 polyline in meters. */ +export function polylineLengthMeters(line: Coordinate[]): number { + let total = 0; + for (let index = 1; index < line.length; index += 1) total += haversineMeters(line[index - 1], line[index]); + return total; +} + +/** + * Local east/north components of a displacement, in meters. Each component is a + * spherical distance measured along a parallel or a meridian, so the result + * carries no Mercator inflation. + */ +export function eastNorthMeters(from: Coordinate, to: Coordinate): { east: number; north: number } { + const [fromLon, fromLat] = from; + const [toLon, toLat] = to; + const midLat = (fromLat + toLat) / 2; + const midLon = (fromLon + toLon) / 2; + const east = haversineMeters([fromLon, midLat], [toLon, midLat]) * Math.sign(toLon - fromLon); + const north = haversineMeters([midLon, fromLat], [midLon, toLat]) * Math.sign(toLat - fromLat); + return { east, north }; +} + +/** + * Signed meters of a 3857 displacement projected onto `axisAzimuth` (degrees + * clockwise from true north). Positive means "along the axis". + */ +export function signedMetersAlongAxis(from: Coordinate, to: Coordinate, axisAzimuth: number): number { + const { east, north } = eastNorthMeters(toLonLat(from), toLonLat(to)); + const azimuth = toRadians(axisAzimuth); + return east * Math.sin(azimuth) + north * Math.cos(azimuth); +} + +/** + * Inverse of the above: how many 3857 units `meters` covers at `latitude`. Ghost + * drawing needs this to turn a constraint value back into an on-screen offset, + * and it is what keeps the drawn edge under the cursor at any latitude. + */ +export function mercatorUnitsForMeters(meters: number, latitude: number): number { + const scale = Math.cos(toRadians(latitude)) * (EARTH_RADIUS_METERS / MERCATOR_RADIUS_METERS); + return scale === 0 ? 0 : meters / scale; +} + +/** Move an EPSG:4326 point `meters` along `azimuth`, on the sphere. */ +export function offsetCoordinate(point: Coordinate, azimuth: number, meters: number): Coordinate { + const angular = meters / EARTH_RADIUS_METERS; + const bearing = toRadians(azimuth); + const lat = toRadians(point[1]); + const lon = toRadians(point[0]); + const nextLat = Math.asin(Math.sin(lat) * Math.cos(angular) + Math.cos(lat) * Math.sin(angular) * Math.cos(bearing)); + const nextLon = + lon + + Math.atan2( + Math.sin(bearing) * Math.sin(angular) * Math.cos(lat), + Math.cos(angular) - Math.sin(lat) * Math.sin(nextLat), + ); + return [toDegrees(nextLon), toDegrees(nextLat)]; +} + +/** Clamp helper shared by the projections. */ +export function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} diff --git a/workbench/client/src/edit/projection.test.ts b/workbench/client/src/edit/projection.test.ts new file mode 100644 index 0000000..d5079d0 --- /dev/null +++ b/workbench/client/src/edit/projection.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from 'vitest'; +import { fromLonLat, offsetCoordinate, type Coordinate } from './meters'; +import { + anchorSnapshotFor, + constraintValueFor, + draftConstraint, + insideReserve, + MIN_INTERVAL_STATION, + projectHandleValue, + projectIntervalEnd, + reserveWindow, +} from './projection'; +import type { ConstraintKind, EditHandle, JunctionReserve, RoadIntervalAnchor, Side } from './types'; + +const CENTER: Coordinate = [116.397, 39.908]; +/** The road heads due north, so the manifest reports normal = tangent + 90 = east. */ +const AXIS = 90; +const TANGENT = 0; + +function interval(side?: Side, boundaryIndex?: number): RoadIntervalAnchor { + return { + type: 'road-interval', + roadId: 'road:way/1:forward', + startStation: 0.2, + endStation: 0.8, + ...(side ? { side } : {}), + ...(boundaryIndex ? { boundaryIndex } : {}), + }; +} + +function makeHandle(kind: ConstraintKind, anchor: RoadIntervalAnchor, current = 0, min = -5, max = 5): EditHandle { + return { + handleId: `handle:${kind}`, + kind, + anchor, + position: CENTER, + axisAzimuth: AXIS, + value: { current, min, max, unit: 'meter' }, + affects: [], + editable: true, + }; +} + +/** A drag of exactly `meters` along `azimuth`, expressed in EPSG:3857. */ +function dragBy(azimuth: number, meters: number): [Coordinate, Coordinate] { + return [fromLonLat(CENTER), fromLonLat(offsetCoordinate(CENTER, azimuth, meters))]; +} + +describe('road-edge-offset projection', () => { + it('widens when the left handle is dragged along the axis', () => { + const handle = makeHandle('road-edge-offset', interval('left')); + expect(projectHandleValue(handle, ...dragBy(AXIS, 2))).toBeCloseTo(2, 3); + }); + + it('widens when the right handle is dragged the opposite way', () => { + // The right handle sits at tangent - 90 while the manifest still reports + // tangent + 90 as its axis, so "outward" is the negative axis direction. + // Both sides must read as a positive offset, or one of them drags inverted. + const handle = makeHandle('road-edge-offset', interval('right')); + expect(projectHandleValue(handle, ...dragBy(AXIS + 180, 2))).toBeCloseTo(2, 3); + }); + + it('narrows when the right handle is dragged inward', () => { + const handle = makeHandle('road-edge-offset', interval('right')); + expect(projectHandleValue(handle, ...dragBy(AXIS, 2))).toBeCloseTo(-2, 3); + }); + + it('adds to the value the manifest already reported', () => { + const handle = makeHandle('road-edge-offset', interval('left'), 1.5); + expect(projectHandleValue(handle, ...dragBy(AXIS, 2))).toBeCloseTo(3.5, 3); + }); + + it('clamps to the manifest range instead of extrapolating', () => { + const handle = makeHandle('road-edge-offset', interval('left'), 0, -1, 1); + expect(projectHandleValue(handle, ...dragBy(AXIS, 9))).toBeCloseTo(1, 6); + expect(projectHandleValue(handle, ...dragBy(AXIS + 180, 9))).toBeCloseTo(-1, 6); + }); + + it('ignores drag perpendicular to the axis', () => { + const handle = makeHandle('road-edge-offset', interval('left'), 1); + expect(projectHandleValue(handle, ...dragBy(TANGENT, 4))).toBeCloseTo(1, 6); + }); +}); + +describe('road-sidewalk-width projection', () => { + it('grows outward and shrinks inward', () => { + const handle = makeHandle('road-sidewalk-width', interval('left'), 2, 0, 8); + expect(projectHandleValue(handle, ...dragBy(AXIS, 1.5))).toBeCloseTo(3.5, 3); + expect(projectHandleValue(handle, ...dragBy(AXIS + 180, 1.5))).toBeCloseTo(0.5, 3); + }); + + it('never produces a negative width', () => { + const handle = makeHandle('road-sidewalk-width', interval('right'), 2, 0, 8); + const scalar = projectHandleValue(handle, ...dragBy(AXIS, 10)); + expect(scalar).toBeCloseTo(0, 6); + expect(constraintValueFor(handle, scalar)).toEqual({ widthMeters: 0, transition: 'smoothstep' }); + }); +}); + +describe('road-lane-divider projection', () => { + it('is a signed lateral offset, with no side flip', () => { + const handle = makeHandle('road-lane-divider', interval(undefined, 2), 0, -1.2, 1.2); + expect(projectHandleValue(handle, ...dragBy(AXIS, 0.8))).toBeCloseTo(0.8, 3); + expect(projectHandleValue(handle, ...dragBy(AXIS + 180, 0.8))).toBeCloseTo(-0.8, 3); + }); + + it('moves boundaryIndex from the manifest anchor into the document value', () => { + const handle = makeHandle('road-lane-divider', interval(undefined, 3), 0, -1, 1); + expect(constraintValueFor(handle, 0.4)).toEqual({ + boundaryIndex: 3, + offsetMeters: 0.4, + transition: 'smoothstep', + }); + }); + + it('adjusts one divider only, not the road width', () => { + // The acceptance criterion is that a divider is not an edge-offset side + // effect: the same drag on both handles must produce different constraint + // shapes, so the solver writes laneDividerOffsets rather than edgeOffsets. + const divider = makeHandle('road-lane-divider', interval(undefined, 1), 0, -1, 1); + const edge = makeHandle('road-edge-offset', interval('left'), 0, -1, 1); + const gesture = dragBy(AXIS, 0.5); + const dividerValue = constraintValueFor(divider, projectHandleValue(divider, ...gesture)); + const edgeValue = constraintValueFor(edge, projectHandleValue(edge, ...gesture)); + expect(dividerValue).toMatchObject({ boundaryIndex: 1, transition: 'smoothstep' }); + expect(edgeValue).not.toHaveProperty('boundaryIndex'); + expect(Object.keys(edgeValue).sort()).toEqual(['offsetMeters', 'transition']); + }); + + it('refuses a divider handle without a boundaryIndex', () => { + const handle = makeHandle('road-lane-divider', interval()); + expect(() => constraintValueFor(handle, 0.4)).toThrow(/boundaryIndex/); + }); +}); + +describe('interval range projection', () => { + const window = { minStation: 0.1, maxStation: 0.9 }; + const LENGTH = 1000; + const move = (meters: number) => dragBy(TANGENT, meters); + + it('moves the dragged end by the station equivalent of the drag', () => { + const next = projectIntervalEnd(interval(), 'start', ...move(100), TANGENT, LENGTH, window); + expect(next.startStation).toBeCloseTo(0.3, 3); + expect(next.endStation).toBe(0.8); + }); + + it('moves the far end independently', () => { + const next = projectIntervalEnd(interval(), 'end', ...move(-100), TANGENT, LENGTH, window); + expect(next.endStation).toBeCloseTo(0.7, 3); + expect(next.startStation).toBe(0.2); + }); + + it('stops at the junction reserve window', () => { + expect(projectIntervalEnd(interval(), 'start', ...move(-400), TANGENT, LENGTH, window).startStation).toBeCloseTo( + 0.1, + 6, + ); + expect(projectIntervalEnd(interval(), 'end', ...move(400), TANGENT, LENGTH, window).endStation).toBeCloseTo(0.9, 6); + }); + + it('never lets the two ends cross', () => { + const start = projectIntervalEnd(interval(), 'start', ...move(900), TANGENT, LENGTH, window); + expect(start.startStation).toBeCloseTo(0.8 - MIN_INTERVAL_STATION, 6); + expect(start.startStation).toBeLessThan(start.endStation); + const end = projectIntervalEnd(interval(), 'end', ...move(-900), TANGENT, LENGTH, window); + expect(end.endStation).toBeCloseTo(0.2 + MIN_INTERVAL_STATION, 6); + expect(end.endStation).toBeGreaterThan(end.startStation); + }); + + it('leaves the interval alone when the road has no length', () => { + expect(projectIntervalEnd(interval(), 'start', ...move(100), TANGENT, 0, window)).toEqual(interval()); + }); +}); + +describe('junction reserves', () => { + const reserves: JunctionReserve[] = [ + { nodeId: '1', roadId: 'segment:1', fromStation: 0, toStation: 0.15 }, + { nodeId: '2', roadId: 'segment:1', fromStation: 0.82, toStation: 1 }, + { nodeId: '3', roadId: 'segment:other', fromStation: 0, toStation: 0.5 }, + ]; + + it('derives the editable window from the reserves of that segment only', () => { + expect(reserveWindow(reserves, 'segment:1')).toEqual({ minStation: 0.15, maxStation: 0.82 }); + expect(reserveWindow(reserves, 'segment:none')).toEqual({ minStation: 0, maxStation: 1 }); + }); + + it('reports stations that fall inside a reserve', () => { + expect(insideReserve(reserves, 'segment:1', 0.05)).toBe(true); + expect(insideReserve(reserves, 'segment:1', 0.5)).toBe(false); + expect(insideReserve(reserves, 'segment:1', 0.9)).toBe(true); + }); +}); + +describe('draft constraint', () => { + const centerline: Coordinate[] = [ + [116.397, 39.9], + [116.397, 39.91], + ]; + const identity = { constraintId: 'c-new', operationId: 'op-1', createdAt: '2026-08-27T00:00:00.000Z' }; + + it('carries the projected value with exact status and provenance', () => { + const handle = makeHandle('road-edge-offset', interval('left')); + const snapshot = anchorSnapshotFor(handle, centerline, ['1001', '1002']); + const constraint = draftConstraint(handle, handle.anchor, { offsetMeters: 2 }, snapshot, identity); + expect(constraint).toMatchObject({ + id: 'c-new', + kind: 'road-edge-offset', + enabled: true, + status: 'exact', + value: { offsetMeters: 2 }, + provenance: { operationId: 'op-1', createdAt: identity.createdAt }, + }); + expect(constraint.anchorSnapshot.roadLengthMeters).toBeGreaterThan(0); + expect(constraint.anchorSnapshot.osmNodeIds).toEqual(['1001', '1002']); + }); + + it('reuses the existing constraint id so a second drag updates in place', () => { + const handle = { ...makeHandle('road-edge-offset', interval('left')), constraintId: 'c-existing' }; + const snapshot = anchorSnapshotFor(handle, centerline, []); + expect(draftConstraint(handle, handle.anchor, { offsetMeters: 1 }, snapshot, identity).id).toBe('c-existing'); + }); + + it('records the tangent, not the drag axis, in the snapshot', () => { + const handle = makeHandle('road-edge-offset', interval('left')); + expect(anchorSnapshotFor(handle, centerline, []).tangentAzimuth).toBeCloseTo(0, 6); + }); +}); diff --git a/workbench/client/src/edit/projection.ts b/workbench/client/src/edit/projection.ts new file mode 100644 index 0000000..3a41e7b --- /dev/null +++ b/workbench/client/src/edit/projection.ts @@ -0,0 +1,179 @@ +// Handle drag -> constraint value. The only place a pointer position becomes a +// number the server will solve with. +// +// Nothing here touches OpenLayers or geometry output: a drag is projected onto +// the axis the manifest declared, converted to meters, clamped to the manifest's +// own range, and packaged as a `native-road-edits/v2` value. The client never +// writes a coordinate into a road polygon. + +import { clamp, haversineMeters, polylineLengthMeters, signedMetersAlongAxis, type Coordinate } from './meters'; +import type { + AnchorSnapshot, + ConstraintValue, + EditHandle, + JunctionReserve, + RoadConstraint, + RoadIntervalAnchor, + Transition, +} from './types'; + +/** Smallest interval the range handles may collapse to, in normalized station. */ +export const MIN_INTERVAL_STATION = 0.02; + +export interface IntervalWindow { + minStation: number; + maxStation: number; +} + +/** + * Turns "along axisAzimuth" into "outward" for a handle. + * + * `makeRoadHandles()` places the left handle at `tangent + 90` and the right one + * at `tangent - 90`, but reports `axisAzimuth = tangent + 90` for both. Since a + * positive `offsetMeters` / `widthMeters` always widens the road, a right-side + * drag measured along that axis has to be negated. Lane dividers are the + * exception: their offset is a signed lateral position already measured along + * the same axis, so the raw projection is the value. + */ +function outwardSign(handle: EditHandle): number { + if (handle.kind === 'road-lane-divider') return 1; + const side = 'side' in handle.anchor ? handle.anchor.side : undefined; + return side === 'right' ? -1 : 1; +} + +/** + * The scalar a drag produces for `handle`, clamped to the range the manifest + * declared. `from` / `to` are EPSG:3857 map coordinates. + */ +export function projectHandleValue(handle: EditHandle, from: Coordinate, to: Coordinate): number { + const delta = signedMetersAlongAxis(from, to, handle.axisAzimuth) * outwardSign(handle); + return clamp(handle.value.current + delta, handle.value.min, handle.value.max); +} + +/** The manifest hangs `boundaryIndex` on the anchor; the document needs it in `value`. */ +function boundaryIndexOf(handle: EditHandle): number { + const index = 'boundaryIndex' in handle.anchor ? handle.anchor.boundaryIndex : undefined; + if (!Number.isInteger(index) || (index as number) < 1) + throw new Error(`handle ${handle.handleId} is a lane divider without an anchor boundaryIndex`); + return index as number; +} + +/** Packages a projected scalar as the `value` the server validates. */ +export function constraintValueFor( + handle: EditHandle, + scalar: number, + transition: Transition = 'smoothstep', +): ConstraintValue { + switch (handle.kind) { + case 'road-edge-offset': + return { offsetMeters: scalar, transition }; + case 'road-sidewalk-width': + return { widthMeters: Math.max(0, scalar), transition }; + case 'road-lane-divider': + return { boundaryIndex: boundaryIndexOf(handle), offsetMeters: scalar, transition }; + case 'junction-approach-width': + return { widthMeters: Math.max(0, scalar) }; + case 'junction-cutback': + return { cutbackMeters: Math.max(0, scalar) }; + case 'junction-corner-radius': + return { radiusMeters: Math.max(0, scalar) }; + } +} + +/** + * The station range a road interval may occupy, i.e. everything outside the + * junction reserves. Mirrors the window `makeRoadHandles()` centres its handles + * in. `reserves` are keyed by segment id, not by directional road id. + */ +export function reserveWindow(reserves: JunctionReserve[], segmentId: string): IntervalWindow { + let minStation = 0; + let maxStation = 1; + for (const reserve of reserves) { + if (reserve.roadId !== segmentId) continue; + if (reserve.fromStation <= 0) minStation = Math.max(minStation, reserve.toStation); + else maxStation = Math.min(maxStation, reserve.fromStation); + } + return { minStation, maxStation }; +} + +/** True when the station sits inside a junction reserve, where the main map may not edit. */ +export function insideReserve(reserves: JunctionReserve[], segmentId: string, station: number): boolean { + return reserves.some( + (reserve) => reserve.roadId === segmentId && station >= reserve.fromStation && station <= reserve.toStation, + ); +} + +/** + * Moves one end of the affected interval. The dragged end stays inside the + * reserve-free window and never crosses the other end, so the solver always + * receives `startStation < endStation` and both ends keep a transition back to + * the baseline. + */ +export function projectIntervalEnd( + anchor: RoadIntervalAnchor, + end: 'start' | 'end', + from: Coordinate, + to: Coordinate, + tangentAzimuth: number, + roadLengthMeters: number, + window: IntervalWindow, +): RoadIntervalAnchor { + if (!(roadLengthMeters > 0)) return anchor; + const deltaStation = signedMetersAlongAxis(from, to, tangentAzimuth) / roadLengthMeters; + if (end === 'start') { + const limit = Math.max(window.minStation, anchor.endStation - MIN_INTERVAL_STATION); + return { ...anchor, startStation: clamp(anchor.startStation + deltaStation, window.minStation, limit) }; + } + const limit = Math.min(window.maxStation, anchor.startStation + MIN_INTERVAL_STATION); + return { ...anchor, endStation: clamp(anchor.endStation + deltaStation, limit, window.maxStation) }; +} + +/** + * Evidence a later reimport uses to relocate this anchor. `centerline` is the + * road's EPSG:4326 centerline from the compiled model. + */ +export function anchorSnapshotFor(handle: EditHandle, centerline: Coordinate[], osmNodeIds: string[]): AnchorSnapshot { + return { + coordinate: [handle.position[0], handle.position[1]], + tangentAzimuth: (((handle.axisAzimuth - 90) % 360) + 360) % 360, + roadLengthMeters: polylineLengthMeters(centerline) || haversineMeters(centerline[0], centerline.at(-1)!) || 1, + osmNodeIds: [...osmNodeIds], + }; +} + +export interface DraftIdentity { + constraintId: string; + operationId: string; + createdAt: string; + author?: string; +} + +/** + * Builds the constraint a drag drafts. `anchor` is passed separately so a range + * drag can widen the interval while the value stays put, and vice versa. + * + * A drafted constraint is always `exact`: it was just authored against the model + * currently on screen. Only a reimport or a compiler upgrade can demote it. + */ +export function draftConstraint( + handle: EditHandle, + anchor: RoadConstraint['anchor'], + value: ConstraintValue, + snapshot: AnchorSnapshot, + identity: DraftIdentity, +): RoadConstraint { + return { + id: handle.constraintId || identity.constraintId, + kind: handle.kind, + anchor, + anchorSnapshot: snapshot, + value, + enabled: true, + status: 'exact', + provenance: { + operationId: identity.operationId, + createdAt: identity.createdAt, + ...(identity.author ? { author: identity.author } : {}), + }, + }; +} diff --git a/workbench/client/src/edit/selection.test.ts b/workbench/client/src/edit/selection.test.ts new file mode 100644 index 0000000..1d5eabe --- /dev/null +++ b/workbench/client/src/edit/selection.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { disabledReasonOf, handlesForSegment, reservesForSegment } from './selection'; +import type { ConstraintKind, EditHandle, HandleManifest, SemanticAnchor } from './types'; + +function handle(handleId: string, kind: ConstraintKind, anchor: SemanticAnchor, editable = true): EditHandle { + return { + handleId, + kind, + anchor, + position: [116.397, 39.908], + axisAzimuth: 90, + value: { current: 0, min: -5, max: 5, unit: 'meter' }, + affects: [], + editable, + ...(editable ? {} : { disabledReason: '该道路全部位于路口保留区,请进入 JunctionTools 编辑。' }), + }; +} + +const roadInterval = (roadId: string): SemanticAnchor => ({ + type: 'road-interval', + roadId, + startStation: 0.15, + endStation: 0.85, + side: 'left', +}); + +// Two segments, plus a junction handle the main map must never render. +const manifest: HandleManifest = { + schema: 'road-edit-handles/v1', + revisionId: 'rev-0001', + previewSeq: 0, + handles: [ + handle('h:edge:a', 'road-edge-offset', roadInterval('road:way/1:forward')), + handle('h:sidewalk:a', 'road-sidewalk-width', roadInterval('road:way/1:forward')), + handle('h:edge:b', 'road-edge-offset', roadInterval('road:way/2:forward')), + handle('h:blocked:a', 'road-lane-divider', roadInterval('road:way/1:forward'), false), + handle('h:approach', 'junction-approach-width', { type: 'junction-approach', nodeId: '9', segmentId: 'seg:1' }), + handle('h:corner', 'junction-corner-radius', { + type: 'junction-corner', + nodeId: '9', + incomingRoadId: 'seg:1', + outgoingRoadId: 'seg:2', + }), + ], + reserves: [ + { nodeId: '9', roadId: 'seg:1', fromStation: 0, toStation: 0.15 }, + { nodeId: '10', roadId: 'seg:1', fromStation: 0.85, toStation: 1 }, + { nodeId: '11', roadId: 'seg:2', fromStation: 0, toStation: 0.3 }, + ], +}; + +const segmentOf = (roadId: string) => + ({ 'road:way/1:forward': 'seg:1', 'road:way/2:forward': 'seg:2' })[roadId] ?? undefined; + +describe('handlesForSegment', () => { + it('keeps only the selected segment', () => { + expect(handlesForSegment(manifest, 'seg:1', segmentOf).map((item) => item.handleId)).toEqual([ + 'h:edge:a', + 'h:sidewalk:a', + 'h:blocked:a', + ]); + expect(handlesForSegment(manifest, 'seg:2', segmentOf).map((item) => item.handleId)).toEqual(['h:edge:b']); + }); + + it('never renders junction kinds on the main map', () => { + // Reserve interiors belong to JunctionTools; offering a second way to edit + // them here is exactly the ownership split design.md forbids. + const kinds = handlesForSegment(manifest, 'seg:1', segmentOf).map((item) => item.kind); + expect(kinds).not.toContain('junction-approach-width'); + expect(kinds).not.toContain('junction-corner-radius'); + }); + + it('keeps disabled handles so the boundary stays explainable', () => { + const blocked = handlesForSegment(manifest, 'seg:1', segmentOf).find((item) => !item.editable); + expect(blocked?.handleId).toBe('h:blocked:a'); + }); + + it('returns nothing without a manifest or a selection', () => { + expect(handlesForSegment(null, 'seg:1', segmentOf)).toEqual([]); + expect(handlesForSegment(manifest, null, segmentOf)).toEqual([]); + }); + + it('drops handles whose road no longer resolves to a segment', () => { + expect(handlesForSegment(manifest, 'seg:1', () => undefined)).toEqual([]); + }); +}); + +describe('reservesForSegment', () => { + it('returns only that segment’s reserves', () => { + expect(reservesForSegment(manifest, 'seg:1').map((item) => item.nodeId)).toEqual(['9', '10']); + expect(reservesForSegment(manifest, 'seg:2').map((item) => item.nodeId)).toEqual(['11']); + }); + + it('returns nothing without a manifest or a selection', () => { + expect(reservesForSegment(null, 'seg:1')).toEqual([]); + expect(reservesForSegment(manifest, null)).toEqual([]); + }); +}); + +describe('disabledReasonOf', () => { + it('says nothing for an editable handle', () => { + expect(disabledReasonOf(handle('h', 'road-edge-offset', roadInterval('road:way/1:forward')))).toBeUndefined(); + }); + + it('passes the server reason through', () => { + const blocked = handle('h', 'road-edge-offset', roadInterval('road:way/1:forward'), false); + expect(disabledReasonOf(blocked)).toContain('JunctionTools'); + }); + + it('always gives some reason, even when the server omitted one', () => { + const blocked = { ...handle('h', 'road-edge-offset', roadInterval('road:way/1:forward'), false) }; + delete blocked.disabledReason; + expect(disabledReasonOf(blocked)).toContain('路口保留区'); + }); +}); diff --git a/workbench/client/src/edit/selection.ts b/workbench/client/src/edit/selection.ts new file mode 100644 index 0000000..de89fdf --- /dev/null +++ b/workbench/client/src/edit/selection.ts @@ -0,0 +1,43 @@ +// selection -> which handles the main map may show. +// +// Pure manifest filtering, deliberately free of any OpenLayers import so it can +// be unit-tested in node. `handle-layer.ts` is the OL adapter that renders the +// result. + +import { isRoadKind, type EditHandle, type HandleManifest, type JunctionReserve } from './types'; + +/** + * Road kinds only, and only for the selected road's segment. + * + * Junction kinds stay out even though the manifest carries them: reserve interiors + * belong to JunctionTools, and the main map must not offer a second way to edit + * them. Reserve-disabled road handles are *kept* rather than dropped, because the + * user has to be able to see why that stretch refuses to move. + * + * Handle anchors carry a directional `roadId`, while `reserves` are keyed by + * segment id, so callers pass a resolver instead of parsing ids apart. + */ +export function handlesForSegment( + manifest: HandleManifest | null, + segmentId: string | null, + segmentOf: (roadId: string) => string | undefined, +): EditHandle[] { + if (!manifest || !segmentId) return []; + return manifest.handles.filter((handle) => { + if (!isRoadKind(handle.kind)) return false; + const roadId = 'roadId' in handle.anchor ? handle.anchor.roadId : undefined; + return typeof roadId === 'string' && segmentOf(roadId) === segmentId; + }); +} + +/** Reserves that apply to one segment, for the range window and the tooltip copy. */ +export function reservesForSegment(manifest: HandleManifest | null, segmentId: string | null): JunctionReserve[] { + if (!manifest || !segmentId) return []; + return manifest.reserves.filter((reserve) => reserve.roadId === segmentId); +} + +/** Why a handle refuses to move. Falls back to a generic reason so the UI never says nothing. */ +export function disabledReasonOf(handle: EditHandle): string | undefined { + if (handle.editable) return undefined; + return handle.disabledReason || '该手柄位于路口保留区,请进入 JunctionTools 编辑。'; +} diff --git a/workbench/client/src/edit/session.test.ts b/workbench/client/src/edit/session.test.ts new file mode 100644 index 0000000..c2a9bed --- /dev/null +++ b/workbench/client/src/edit/session.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest'; +import { EditSession } from './session'; +import type { EditDiagnostic, EditPreviewResponse, RoadConstraint } from './types'; + +function constraint(id: string, offsetMeters: number): RoadConstraint { + return { + id, + kind: 'road-edge-offset', + anchor: { type: 'road-interval', roadId: 'road:way/1:forward', startStation: 0.2, endStation: 0.8, side: 'left' }, + anchorSnapshot: { coordinate: [116.397, 39.908], tangentAzimuth: 0, roadLengthMeters: 500, osmNodeIds: ['1'] }, + value: { offsetMeters }, + enabled: true, + status: 'exact', + provenance: { operationId: 'op', createdAt: '2026-08-27T00:00:00.000Z' }, + }; +} + +const identity = (n: number) => ({ operationId: `op-${n}`, createdAt: `2026-08-27T00:00:0${n}.000Z` }); + +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: {}, + }; +} + +const error: EditDiagnostic = { + id: 'd1', + message: '车道分隔调整会使相邻车道小于 2.4 米。', + rule: 'direct-edit-min-lane-width', + severity: 'error', +}; + +describe('command stack', () => { + it('starts clean on the loaded baseline', () => { + const session = new EditSession([constraint('c1', 1)], 3); + expect(session.constraints()).toHaveLength(1); + expect(session.canUndo).toBe(false); + expect(session.canRedo).toBe(false); + expect(session.dirty).toBe(false); + expect(session.documentVersion).toBe(3); + }); + + it('applies one gesture as one undoable command', () => { + const session = new EditSession([]); + session.commit([constraint('c1', 2)], ['c1'], identity(1)); + expect(session.constraints()).toEqual([constraint('c1', 2)]); + expect(session.canUndo).toBe(true); + expect(session.dirty).toBe(true); + }); + + it('undoes and redoes an unsaved command', () => { + const session = new EditSession([]); + session.commit([constraint('c1', 2)], ['c1'], identity(1)); + session.commit([constraint('c1', 5)], ['c1'], identity(2)); + expect(session.undo()).toBe(true); + expect(session.constraints()).toEqual([constraint('c1', 2)]); + expect(session.undo()).toBe(true); + expect(session.constraints()).toEqual([]); + expect(session.undo()).toBe(false); + expect(session.redo()).toBe(true); + expect(session.redo()).toBe(true); + expect(session.constraints()).toEqual([constraint('c1', 5)]); + expect(session.redo()).toBe(false); + }); + + it('drops the redo tail once a new gesture is committed', () => { + const session = new EditSession([]); + session.commit([constraint('c1', 2)], ['c1'], identity(1)); + session.commit([constraint('c1', 5)], ['c1'], identity(2)); + session.undo(); + session.commit([constraint('c1', 9)], ['c1'], identity(3)); + expect(session.canRedo).toBe(false); + expect(session.constraints()).toEqual([constraint('c1', 9)]); + }); + + it('reports each unsaved gesture as one operation, oldest first', () => { + const session = new EditSession([]); + session.commit([constraint('c1', 2)], ['c1'], identity(1)); + session.commit([constraint('c1', 2), constraint('c2', 1)], ['c2'], identity(2)); + expect(session.pendingOperations()).toEqual([ + { id: 'op-1', createdAt: '2026-08-27T00:00:01.000Z', constraintIds: ['c1'] }, + { id: 'op-2', createdAt: '2026-08-27T00:00:02.000Z', constraintIds: ['c2'] }, + ]); + }); +}); + +describe('saved history is append-only', () => { + it('clears the pending list and the dirty flag on save', () => { + const session = new EditSession([]); + session.commit([constraint('c1', 2)], ['c1'], identity(1)); + session.markSaved(4); + expect(session.dirty).toBe(false); + expect(session.pendingOperations()).toEqual([]); + expect(session.documentVersion).toBe(4); + }); + + it('undoes a saved gesture by appending an inverse, not by rewriting it', () => { + const session = new EditSession([]); + session.commit([constraint('c1', 2)], ['c1'], identity(1)); + session.markSaved(4); + expect(session.undo(identity(2))).toBe(true); + expect(session.constraints()).toEqual([]); + // The original operation stays; a second one records that it was reversed. + expect(session.pendingOperations()).toEqual([ + { id: 'op-2', createdAt: '2026-08-27T00:00:02.000Z', constraintIds: ['c1'], inverseOf: 'op-1' }, + ]); + expect(session.dirty).toBe(true); + }); + + it('refuses to undo saved history without an identity for the inverse', () => { + const session = new EditSession([]); + session.commit([constraint('c1', 2)], ['c1'], identity(1)); + session.markSaved(4); + expect(session.undo()).toBe(false); + expect(session.constraints()).toEqual([constraint('c1', 2)]); + }); + + it('adopts the server version after a conflict', () => { + const session = new EditSession([], 1); + session.setDocumentVersion(7); + expect(session.documentVersion).toBe(7); + }); +}); + +describe('previewSeq arbitration', () => { + it('hands out a monotonic sequence', () => { + const session = new EditSession([]); + expect([session.nextPreviewSeq(), session.nextPreviewSeq(), session.nextPreviewSeq()]).toEqual([1, 2, 3]); + }); + + it('discards a response that arrives after a newer one', () => { + const session = new EditSession([]); + expect(session.acceptPreview(preview(5)).applied).toBe(true); + expect(session.acceptPreview(preview(4)).applied).toBe(false); + expect(session.lastValidPreview()?.previewSeq).toBe(5); + }); + + it('accepts responses in order and keeps the newest', () => { + const session = new EditSession([]); + session.acceptPreview(preview(1)); + session.acceptPreview(preview(2)); + expect(session.lastValidPreview()?.previewSeq).toBe(2); + }); + + it('lets a stale response through only if nothing newer was applied', () => { + const session = new EditSession([]); + expect(session.acceptPreview(preview(0)).applied).toBe(true); + expect(session.lastValidPreview()?.previewSeq).toBe(0); + }); + + it('keeps the last valid preview when the draft is rejected', () => { + const session = new EditSession([]); + session.acceptPreview(preview(1)); + const outcome = session.acceptPreview(preview(2, [error])); + expect(outcome).toEqual({ applied: true, blocked: true }); + expect(session.lastValidPreview()?.previewSeq).toBe(1); + expect(session.diagnostics).toEqual([error]); + }); + + it('does not let an older valid response overwrite a rejected newer one', () => { + // The watermark advances even on rejection, so a late reply for an earlier + // drag cannot resurrect itself on top of the current diagnostics. + const session = new EditSession([]); + session.acceptPreview(preview(1)); + session.acceptPreview(preview(3, [error])); + expect(session.acceptPreview(preview(2)).applied).toBe(false); + expect(session.lastValidPreview()?.previewSeq).toBe(1); + }); + + it('surfaces a warning without blocking', () => { + const session = new EditSession([]); + const warning: EditDiagnostic = { id: 'd2', message: '提示', rule: 'advisory', severity: 'warning' }; + expect(session.acceptPreview(preview(1, [warning])).blocked).toBe(false); + expect(session.lastValidPreview()?.previewSeq).toBe(1); + }); + + it('reports a degraded preview so the ghost can stay pending', () => { + const session = new EditSession([]); + session.acceptPreview({ ...preview(1), degraded: true }); + expect(session.degraded).toBe(true); + }); +}); diff --git a/workbench/client/src/edit/session.ts b/workbench/client/src/edit/session.ts new file mode 100644 index 0000000..37901ca --- /dev/null +++ b/workbench/client/src/edit/session.ts @@ -0,0 +1,182 @@ +// EditSession — the pure logic behind direct manipulation. +// +// No OpenLayers reference lives here on purpose: the command stack, undo/redo +// and previewSeq arbitration are the parts most likely to break subtly, so they +// stay unit-testable in node. The map is an adapter that feeds this object +// pointer events and renders what it reports. +// +// Preview arbitration sits here rather than in the request layer because the +// discard decision is a rule worth testing; the request layer only sends and +// cancels. + +import { isBlocking, type ConstraintStateReport, type EditDiagnostic } from './types'; +import type { EditPreviewResponse, RoadConstraint, RoadEditOperation } from './types'; + +export interface CommandIdentity { + operationId: string; + createdAt: string; + author?: string; +} + +interface Command extends CommandIdentity { + constraintIds: string[]; + /** Full constraint set before and after this command. */ + before: RoadConstraint[]; + after: RoadConstraint[]; + /** Set once the command has been persisted; saved history is append-only. */ + saved: boolean; + inverseOf?: string; +} + +export interface PreviewOutcome { + /** False when the response was stale and dropped. */ + applied: boolean; + /** True when the draft was rejected, so the last valid preview stays on screen. */ + blocked: boolean; +} + +export class EditSession { + private baseline: RoadConstraint[]; + private stack: Command[] = []; + private cursor = 0; + private seq = 0; + /** Highest previewSeq already acted on. -1 means nothing applied yet. */ + private appliedSeq = -1; + private preview: EditPreviewResponse | null = null; + private version: number; + + diagnostics: EditDiagnostic[] = []; + constraintStates: ConstraintStateReport[] = []; + degraded = false; + + constructor(constraints: RoadConstraint[] = [], documentVersion = 0) { + this.baseline = [...constraints]; + this.version = documentVersion; + } + + /** Effective constraint set: the top applied command, or the saved baseline. */ + constraints(): RoadConstraint[] { + return this.cursor > 0 ? this.stack[this.cursor - 1].after : this.baseline; + } + + get documentVersion(): number { + return this.version; + } + + get canUndo(): boolean { + return this.cursor > 0; + } + + get canRedo(): boolean { + return this.cursor < this.stack.length; + } + + /** Unsaved commands exist, so there is something to save or discard. */ + get dirty(): boolean { + return this.stack.slice(0, this.cursor).some((command) => !command.saved); + } + + /** Last authoritative preview; null until one has been accepted. */ + lastValidPreview(): EditPreviewResponse | null { + return this.preview; + } + + /** + * Pushes one gesture as a single undoable command. Committing after an undo + * drops the redo tail, matching every other editor. + */ + commit(next: RoadConstraint[], constraintIds: string[], identity: CommandIdentity): void { + const before = this.constraints(); + this.stack = [ + ...this.stack.slice(0, this.cursor), + { ...identity, constraintIds, before, after: [...next], saved: false }, + ]; + this.cursor = this.stack.length; + } + + /** + * Undo. An unsaved command is simply un-applied. A saved one is reversed by + * appending a forward inverse command, because persisted history must not be + * rewritten — the document keeps both operations, the second undoing the first. + */ + undo(identity?: CommandIdentity): boolean { + const command = this.stack[this.cursor - 1]; + if (!command) return false; + if (!command.saved) { + this.cursor -= 1; + return true; + } + if (!identity) return false; + this.stack = [ + ...this.stack.slice(0, this.cursor), + { + ...identity, + constraintIds: command.constraintIds, + before: command.after, + after: command.before, + saved: false, + inverseOf: command.operationId, + }, + ]; + this.cursor = this.stack.length; + return true; + } + + redo(): boolean { + if (!this.canRedo) return false; + this.cursor += 1; + return true; + } + + /** Operations that still need persisting, oldest first. */ + pendingOperations(): RoadEditOperation[] { + return this.stack + .slice(0, this.cursor) + .filter((command) => !command.saved) + .map((command) => ({ + id: command.operationId, + createdAt: command.createdAt, + constraintIds: [...command.constraintIds], + ...(command.author ? { author: command.author } : {}), + ...(command.inverseOf ? { inverseOf: command.inverseOf } : {}), + })); + } + + /** After a successful save: current state becomes the baseline, history closes. */ + markSaved(documentVersion: number): void { + this.baseline = this.constraints(); + this.stack = this.stack.slice(0, this.cursor).map((command) => ({ ...command, saved: true })); + this.cursor = this.stack.length; + this.version = documentVersion; + } + + /** Adopt the server's version, e.g. after a 409 conflict reports the current one. */ + setDocumentVersion(documentVersion: number): void { + this.version = documentVersion; + } + + /** Monotonic per-session sequence stamped on every preview request. */ + nextPreviewSeq(): number { + this.seq += 1; + return this.seq; + } + + /** + * Arbitrates one preview response. + * + * A response older than the newest one already acted on is dropped outright, + * so an out-of-order reply can never overwrite a newer preview. A rejected + * draft still advances the watermark — it just keeps the last valid preview on + * screen and surfaces the diagnostics instead. + */ + acceptPreview(response: EditPreviewResponse): PreviewOutcome { + if (response.previewSeq < this.appliedSeq) return { applied: false, blocked: false }; + this.appliedSeq = response.previewSeq; + this.diagnostics = response.diagnostics ?? []; + this.constraintStates = response.constraintStates ?? []; + this.degraded = Boolean(response.degraded); + const blocked = this.diagnostics.some(isBlocking); + if (!blocked) this.preview = response; + return { applied: true, blocked }; + } +} diff --git a/workbench/client/src/edit/types.ts b/workbench/client/src/edit/types.ts new file mode 100644 index 0000000..bf5108d --- /dev/null +++ b/workbench/client/src/edit/types.ts @@ -0,0 +1,216 @@ +// Single owner for the direct-edit wire contract. +// +// The server defines these shapes in two places: `src/compile/direct-edit-solver.js` +// emits the handle manifest and preview payload, `src/compile/native-road-edits.js` +// validates the persisted `native-road-edits/v2` document. Every client consumer +// imports from here rather than casting response fields locally, so there is one +// definition of "a valid manifest" on this side of the wire. + +import type { GeoJson } from '../types/state'; + +export type Side = 'left' | 'right'; +export type Transition = 'smoothstep' | 'linear'; + +export const HANDLE_MANIFEST_SCHEMA = 'road-edit-handles/v1'; +export const EDITS_SCHEMA = 'native-road-edits/v2'; + +/** Kinds the main map owns. Junction kinds belong to JunctionTools. */ +export const ROAD_KINDS = ['road-edge-offset', 'road-sidewalk-width', 'road-lane-divider'] as const; +export const JUNCTION_KINDS = ['junction-approach-width', 'junction-cutback', 'junction-corner-radius'] as const; + +export type RoadConstraintKind = (typeof ROAD_KINDS)[number]; +export type JunctionConstraintKind = (typeof JUNCTION_KINDS)[number]; +export type ConstraintKind = RoadConstraintKind | JunctionConstraintKind; + +export type ConstraintStatus = 'exact' | 'recheck' | 'pending' | 'conflicted' | 'stale'; + +export type SemanticAnchor = + | { type: 'road-station'; roadId: string; station: number; side?: Side } + | { + type: 'road-interval'; + roadId: string; + startStation: number; + endStation: number; + side?: Side; + // The manifest hangs boundaryIndex on the anchor for lane dividers, but the + // persisted document carries it in `value.boundaryIndex`. `draftConstraint` + // in projection.ts is the only place that moves it across. + boundaryIndex?: number; + } + | { type: 'junction-approach'; nodeId: string; segmentId: string; side?: Side } + | { type: 'junction-corner'; nodeId: string; incomingRoadId: string; outgoingRoadId: string }; + +export type RoadIntervalAnchor = Extract; + +export interface EditHandle { + handleId: string; + kind: ConstraintKind; + anchor: SemanticAnchor; + /** EPSG:4326 [lon, lat]. */ + position: [number, number]; + /** Draggable direction, degrees clockwise from true north. */ + axisAzimuth: number; + value: { current: number; min: number; max: number; unit: 'meter' }; + constraintId?: string; + /** `native_id` of every derived feature this handle changes. */ + affects: string[]; + editable: boolean; + disabledReason?: string; +} + +export interface JunctionReserve { + nodeId: string; + roadId: string; + fromStation: number; + toStation: number; +} + +export interface HandleManifest { + schema: typeof HANDLE_MANIFEST_SCHEMA; + revisionId: string | null; + previewSeq: number; + handles: EditHandle[]; + reserves: JunctionReserve[]; +} + +export interface AnchorSnapshot { + coordinate: [number, number]; + tangentAzimuth: number; + roadLengthMeters: number; + osmNodeIds: string[]; +} + +export type ConstraintValue = + | { offsetMeters: number; transition?: Transition } + | { widthMeters: number; transition?: Transition } + | { boundaryIndex: number; offsetMeters: number; transition?: Transition } + | { cutbackMeters: number } + | { radiusMeters: number }; + +export interface RoadConstraint { + id: string; + kind: ConstraintKind; + anchor: SemanticAnchor; + anchorSnapshot: AnchorSnapshot; + value: ConstraintValue; + enabled: boolean; + status: ConstraintStatus; + provenance: { operationId: string; createdAt: string; author?: string }; +} + +export interface RoadEditOperation { + id: string; + createdAt: string; + constraintIds: string[]; + author?: string; + inverseOf?: string; +} + +export interface RoadEditDocument { + schema: typeof EDITS_SCHEMA; + documentVersion: number; + base: { osmSha256: string | null; areaConfigSha256: string | null; compilerGeometryVersion: string | null }; + constraints: RoadConstraint[]; + operations: RoadEditOperation[]; +} + +export interface EditDiagnostic { + id: string; + message: string; + rule: string; + severity?: string; + subjectId?: string; + geometry?: { type: string; coordinates: unknown }; +} + +export interface ConstraintStateReport { + constraintId: string; + kind: ConstraintKind; + status: ConstraintStatus; + applied: boolean; + reason: string | null; +} + +/** Preview layer names the server replaces. Anything absent stays untouched. */ +export const PREVIEW_LAYERS = [ + 'roadSurface', + 'edgeLines', + 'sidewalkSurface', + 'intersectionSurface', + 'laneCenterlines', + 'laneSeparators', + 'centerLines', + 'directionArrows', + 'turnArrows', + 'crosswalks', + 'vehicleStopLines', + 'connectors', +] as const; + +export type PreviewLayerName = (typeof PREVIEW_LAYERS)[number]; + +export interface EditPreviewResponse { + ok: true; + previewSeq: number; + degraded: boolean; + revisionId: string | null; + documentVersion: number; + constraintStates: ConstraintStateReport[]; + diagnostics: EditDiagnostic[]; + handles: HandleManifest; + layers: Partial>; +} + +export interface EditStateResponse { + active: true; + areaId: string; + activeRevisionId: string | null; + documentVersion: number; + document: RoadEditDocument; + constraintStates: ConstraintStateReport[]; + diagnostics: EditDiagnostic[]; + handles: HandleManifest; +} + +/** + * `POST /api/edit-preview`. Sending `constraints` alone lets the server merge + * them onto the active document; `document` replaces it wholesale. `previewSeq` + * comes from `EditSession.nextPreviewSeq()` and is echoed back for arbitration. + */ +export interface EditPreviewRequest { + previewSeq: number; + constraints?: RoadConstraint[]; + operations?: RoadEditOperation[]; + document?: RoadEditDocument; +} + +/** `POST /api/edits`. The version guard is what makes a second tab fail loudly. */ +export interface SaveEditsRequest { + expectedDocumentVersion: number; + constraints?: RoadConstraint[]; + operations?: RoadEditOperation[]; + document?: RoadEditDocument; +} + +export interface SaveEditsResponse { + ok: true; + document: RoadEditDocument; + documentVersion: number; + activeRevisionId: string | null; +} + +/** 409 body from `POST /api/edits`: the version the server actually holds. */ +export interface VersionConflict { + ok: false; + error: string; + current: { documentVersion: number }; +} + +export function isRoadKind(kind: ConstraintKind): kind is RoadConstraintKind { + return (ROAD_KINDS as readonly string[]).includes(kind); +} + +/** True when a diagnostic must block committing the draft. */ +export function isBlocking(diagnostic: EditDiagnostic): boolean { + return diagnostic.severity === 'error'; +} diff --git a/workbench/client/src/lib/api.ts b/workbench/client/src/lib/api.ts index b4ad3aa..78dc70a 100644 --- a/workbench/client/src/lib/api.ts +++ b/workbench/client/src/lib/api.ts @@ -1,12 +1,54 @@ import type { WorkbenchState } from '../types/state'; +import type { + EditPreviewRequest, + EditPreviewResponse, + EditStateResponse, + SaveEditsRequest, + SaveEditsResponse, +} from '../edit/types'; + +/** Carries the status and parsed body so callers can branch on 409 rather than on message text. */ +export class ApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly body: unknown, + ) { + super(message); + this.name = 'ApiError'; + } +} async function request(path: string, init?: RequestInit): Promise { const response = await fetch(path, { cache: 'no-store', ...init }); - const value = (await response.json()) as T & { ok?: boolean; error?: string }; - if (!response.ok || value.ok === false) throw new Error(value.error || `HTTP ${response.status}`); + // Read as text first. A dead backend behind the dev proxy answers with an empty + // body, and `response.json()` then throws "Unexpected end of JSON input" — + // which says nothing about the actual problem. + const text = await response.text(); + if (!text) throw new ApiError(`${path} 返回空响应(HTTP ${response.status}),后端可能未启动`, response.status, null); + let value: T & { ok?: boolean; error?: string }; + try { + value = JSON.parse(text) as T & { ok?: boolean; error?: string }; + } catch { + throw new ApiError(`${path} 返回了非 JSON 响应(HTTP ${response.status})`, response.status, text); + } + if (!response.ok || value.ok === false) + throw new ApiError(value.error || `HTTP ${response.status}`, response.status, value); return value; } +const json = (body: unknown, signal?: AbortSignal): RequestInit => ({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + ...(signal ? { signal } : {}), +}); + +/** A save lost the single-writer race; `body.current.documentVersion` holds the server's version. */ +export function isVersionConflict(error: unknown): error is ApiError { + return error instanceof ApiError && error.status === 409; +} + export const api = { state: () => request('/api/state'), import: (file: File) => { @@ -15,26 +57,29 @@ export const api = { return request('/api/import', { method: 'POST', body }); }, overrides: (overrides: unknown[]) => - request<{ overrides: WorkbenchState['overrides'] }>('/api/overrides', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ schema: 'native-road-overrides/v1', overrides }), - }), + request<{ overrides: WorkbenchState['overrides'] }>( + '/api/overrides', + json({ schema: 'native-road-overrides/v1', overrides }), + ), compile: () => request('/api/compile', { method: 'POST' }), signals: (document: unknown) => - request>('/api/traffic-signals', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(document), - }), + request>('/api/traffic-signals', json(document)), generateSignals: () => request>('/api/traffic-signals/generate', { method: 'POST', }), acceptJunctionCandidate: (index: number) => - request('/api/junction-clusters', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ index }), - }), + request('/api/junction-clusters', json({ index })), + + // Direct edit. Only reached when the `directEdit` flag is on; with it off the + // workbench never touches these routes, so behaviour matches main exactly. + editState: () => request('/api/edit-state'), + /** + * `signal` comes from the caller's AbortController: the request layer only + * sends and cancels, while `EditSession` decides which responses to keep. + */ + editPreview: (body: EditPreviewRequest, signal?: AbortSignal) => + request('/api/edit-preview', json(body, signal)), + /** Throws an `ApiError` with status 409 when another writer moved the version. */ + saveEdits: (body: SaveEditsRequest) => request('/api/edits', json(body)), };