feat: add direct edit handles behind directEdit flag
Steps 1-3 of the main map road interval editor. EditSession keeps the command stack, undo/redo and previewSeq arbitration as pure logic with no OpenLayers reference, so all of it is unit-tested in node. Pointer displacement converts to meters through EPSG:4326 and spherical distance: treating a 3857 delta as meters desyncs the geometry from the cursor by 1/cos(latitude). Handle drags project onto the axis the manifest declares and clamp to its range, so the client never writes a coordinate into a road polygon. All of it sits behind a directEdit flag that defaults to off. With the flag off the workbench requests no manifest, creates no extra source and registers no interaction, so behaviour matches main. The ol-ext probe passed its three gates but is not adopted for road handles. Transform translates by the raw pointer delta, so a handle detaches from its clamped constraint value: a drag reading -24.1 m produced a draft of -5.4 m. Production needs the handle position derived from the constraint instead, which means owning the position update, so native OL PointerInteraction will carry the drag. ol-ext stays out of package.json; the probe is kept as a manual harness. Reserve handles are unreachable with the current solver, recorded in research/ rather than worked around. Also names the dead backend when an API response is empty, instead of surfacing "Unexpected end of JSON input" from response.json(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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` 全部保留,探针已验证它们可用。
|
||||
@@ -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` 任务范围),
|
||||
不在本任务范围内,故此处只记录不实施。
|
||||
@@ -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,
|
||||
|
||||
269
workbench/client/probe/ol-ext-transform/ProbeMap.tsx
Normal file
269
workbench/client/probe/ol-ext-transform/ProbeMap.tsx
Normal file
@@ -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<HTMLDivElement>(null);
|
||||
const renders = useRef(0);
|
||||
const [report, setReport] = useState<Report>(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 (
|
||||
<div className="probe">
|
||||
<div className="probe-map" ref={target} aria-label="ol-ext 探针地图" />
|
||||
<aside className="probe-panel">
|
||||
<h1>ol-ext Transform 探针</h1>
|
||||
|
||||
<section className="probe-howto">
|
||||
<h2>你要做的事(约 30 秒)</h2>
|
||||
<ol>
|
||||
<li>
|
||||
按住地图上那个<strong>蓝色圆点</strong>,<strong>左右</strong>慢慢拖动
|
||||
</li>
|
||||
<li>盯住下面那个大字数字,看它跟不跟手</li>
|
||||
<li>再故意快速、大幅度拖十几次,试着把它拖崩</li>
|
||||
</ol>
|
||||
<p>全程那块灰绿色道路面必须一动不动。</p>
|
||||
</section>
|
||||
|
||||
<section className="probe-live">
|
||||
<h2>跟手位移</h2>
|
||||
<strong className="probe-big">
|
||||
{report.rawMeters === null ? '拖一下试试' : `${report.rawMeters.toFixed(2)} 米`}
|
||||
</strong>
|
||||
<p>拖的时候它应该连续变化、不跳数;松手之后不应该自己变。</p>
|
||||
</section>
|
||||
|
||||
<section className="probe-ask">
|
||||
<h2>回报给我这三个答案</h2>
|
||||
<ol>
|
||||
<li>拖动时数字连续吗?会不会卡顿、跳数、突然归零?</li>
|
||||
<li>松开鼠标后,数字会不会自己弹回去或突变?</li>
|
||||
<li>反复拖十几次,蓝点会不会抓不住?页面会不会报错?</li>
|
||||
</ol>
|
||||
<p>页面若崩溃,会显示红色报错框,把那整段发我即可。</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>自动判定,不用你操心</h2>
|
||||
<dl>
|
||||
<dt>地图没被重建</dt>
|
||||
<dd>
|
||||
{pass(mapInstances === report.mapInstanceId)}(实例 #{report.mapInstanceId}/共构造 {mapInstances} 次,
|
||||
React 渲染 {renders.current} 次)
|
||||
</dd>
|
||||
<dt>基线道路面没被改写</dt>
|
||||
<dd>
|
||||
{pass(report.baselineWrites === 0 && !report.baselineChanged)}(写入 {report.baselineWrites} 次,几何
|
||||
{report.baselineChanged ? '已改变' : '未改变'})
|
||||
</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<details>
|
||||
<summary>技术细节</summary>
|
||||
<p>
|
||||
完成 {report.drags} 次拖拽,收到 {report.moves} 个 translating 事件。
|
||||
<br />
|
||||
约束值(带生产钳位 ±{PROBE_HANDLE.value.max.toFixed(1)} 米)
|
||||
{report.draftMeters === null ? ' —' : ` ${report.draftMeters.toFixed(3)} 米`}
|
||||
{saturated ? ',已顶到边界,属正常' : ''}。
|
||||
</p>
|
||||
<button type="button" onClick={() => setNudge(nudge + 1)}>
|
||||
强制 React 重渲染(已 {nudge} 次)
|
||||
</button>
|
||||
<ul>
|
||||
{report.log.map((line, index) => (
|
||||
<li key={`${line}-${index}`}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
workbench/client/probe/ol-ext-transform/README.md
Normal file
68
workbench/client/probe/ol-ext-transform/README.md
Normal file
@@ -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
|
||||
```
|
||||
|
||||
然后打开 <http://localhost:5173/probe/ol-ext-transform/>。
|
||||
|
||||
不需要导入 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` 全部保留。
|
||||
61
workbench/client/probe/ol-ext-transform/fixture.ts
Normal file
61
workbench/client/probe/ol-ext-transform/fixture.ts
Normal file
@@ -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,
|
||||
};
|
||||
12
workbench/client/probe/ol-ext-transform/index.html
Normal file
12
workbench/client/probe/ol-ext-transform/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ol-ext Transform 限时探针</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="probe"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
61
workbench/client/probe/ol-ext-transform/main.tsx
Normal file
61
workbench/client/probe/ol-ext-transform/main.tsx
Normal file
@@ -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 (
|
||||
<section className="probe-error">
|
||||
<h1>探针崩溃了</h1>
|
||||
<p>请把下面整段发给我 —— 它决定这次失败算谁的。</p>
|
||||
<pre>
|
||||
{error.name}: {error.message}
|
||||
{'\n\n'}
|
||||
{error.stack}
|
||||
</pre>
|
||||
<button type="button" onClick={() => this.setState({ error: null })}>
|
||||
重试
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
<nav className="probe-nav">
|
||||
<strong>ol-ext Transform 限时探针</strong>
|
||||
<span>隔离页面,不接 API,不影响生产图层</span>
|
||||
<button type="button" onClick={() => setMounted(!mounted)}>
|
||||
{mounted ? '卸载地图' : '重新挂载地图'}
|
||||
</button>
|
||||
</nav>
|
||||
{mounted ? <ProbeMap /> : <p className="probe-empty">地图已卸载。重新挂载会构造新的 Map,这是预期行为。</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('probe')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Probe />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
);
|
||||
57
workbench/client/probe/ol-ext-transform/ol-ext.d.ts
vendored
Normal file
57
workbench/client/probe/ol-ext-transform/ol-ext.d.ts
vendored
Normal file
@@ -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<Feature>;
|
||||
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<Feature>;
|
||||
/** `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;
|
||||
}
|
||||
}
|
||||
148
workbench/client/probe/ol-ext-transform/probe.css
Normal file
148
workbench/client/probe/ol-ext-transform/probe.css
Normal file
@@ -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;
|
||||
}
|
||||
7
workbench/client/probe/ol-ext-transform/tsconfig.json
Normal file
7
workbench/client/probe/ol-ext-transform/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [".", "../../src/edit"]
|
||||
}
|
||||
@@ -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<HandleManifest | null>(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() {
|
||||
<span className={staged.length ? 'dirty' : ''}>
|
||||
{staged.length ? `未保存修改 ${staged.length} 项` : '所有修改已保存'}
|
||||
</span>
|
||||
{directEditEnabled ? (
|
||||
<span>
|
||||
{editHandles.length
|
||||
? `手柄 ${editHandles.length} 个`
|
||||
: selected
|
||||
? '当前道路无可编辑手柄'
|
||||
: '选中道路以显示手柄'}
|
||||
{blockedReason ? `|${blockedReason}` : ''}
|
||||
</span>
|
||||
) : null}
|
||||
<label className="scene">
|
||||
<input type="checkbox" checked={scene} onChange={(event) => setScene(event.target.checked)} /> 场景效果
|
||||
</label>
|
||||
@@ -213,6 +250,7 @@ function App() {
|
||||
scene={scene}
|
||||
onSelectRoad={setSelected}
|
||||
onFeature={handleFeature}
|
||||
handles={editHandles}
|
||||
/>
|
||||
<Inspector
|
||||
road={selected}
|
||||
|
||||
@@ -5,6 +5,9 @@ import Select from 'ol/interaction/Select';
|
||||
import { click } from 'ol/events/condition';
|
||||
import type { Road, WorkbenchState } from '../types/state';
|
||||
import { createLayers, updateLayers, updateSelectedRoad, type LayerName } from '../map/layers';
|
||||
import { directEditEnabled } from '../edit/flag';
|
||||
import { EditHandleLayer } from '../edit/handle-layer';
|
||||
import type { EditHandle } from '../edit/types';
|
||||
|
||||
interface Props {
|
||||
state: WorkbenchState;
|
||||
@@ -13,11 +16,14 @@ interface Props {
|
||||
scene: boolean;
|
||||
onSelectRoad: (road: Road) => void;
|
||||
onFeature: (properties: Record<string, unknown>) => 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<HTMLDivElement>(null);
|
||||
const mapRef = useRef<Map | null>(null);
|
||||
const layersRef = useRef<ReturnType<typeof createLayers> | null>(null);
|
||||
const handleLayerRef = useRef<EditHandleLayer | null>(null);
|
||||
const selectedRef = useRef<Road | null>(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;
|
||||
|
||||
31
workbench/client/src/edit/flag.ts
Normal file
31
workbench/client/src/edit/flag.ts
Normal file
@@ -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();
|
||||
105
workbench/client/src/edit/handle-layer.ts
Normal file
105
workbench/client/src/edit/handle-layer.ts
Normal file
@@ -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<string, Style> = {
|
||||
'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<VectorSource>;
|
||||
private readonly source = new VectorSource();
|
||||
private index = new Map<string, EditHandle>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
115
workbench/client/src/edit/meters.test.ts
Normal file
115
workbench/client/src/edit/meters.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
112
workbench/client/src/edit/meters.ts
Normal file
112
workbench/client/src/edit/meters.ts
Normal file
@@ -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));
|
||||
}
|
||||
227
workbench/client/src/edit/projection.test.ts
Normal file
227
workbench/client/src/edit/projection.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
179
workbench/client/src/edit/projection.ts
Normal file
179
workbench/client/src/edit/projection.ts
Normal file
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
115
workbench/client/src/edit/selection.test.ts
Normal file
115
workbench/client/src/edit/selection.test.ts
Normal file
@@ -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('路口保留区');
|
||||
});
|
||||
});
|
||||
43
workbench/client/src/edit/selection.ts
Normal file
43
workbench/client/src/edit/selection.ts
Normal file
@@ -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 编辑。';
|
||||
}
|
||||
190
workbench/client/src/edit/session.test.ts
Normal file
190
workbench/client/src/edit/session.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
182
workbench/client/src/edit/session.ts
Normal file
182
workbench/client/src/edit/session.ts
Normal file
@@ -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 };
|
||||
}
|
||||
}
|
||||
216
workbench/client/src/edit/types.ts
Normal file
216
workbench/client/src/edit/types.ts
Normal file
@@ -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<SemanticAnchor, { type: 'road-interval' }>;
|
||||
|
||||
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<Record<PreviewLayerName, GeoJson | null>>;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<WorkbenchState | { active: false }>('/api/state'),
|
||||
import: (file: File) => {
|
||||
@@ -15,26 +57,29 @@ export const api = {
|
||||
return request<WorkbenchState>('/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<WorkbenchState>('/api/compile', { method: 'POST' }),
|
||||
signals: (document: unknown) =>
|
||||
request<Pick<WorkbenchState, 'trafficSignals' | 'trafficRuntime'>>('/api/traffic-signals', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(document),
|
||||
}),
|
||||
request<Pick<WorkbenchState, 'trafficSignals' | 'trafficRuntime'>>('/api/traffic-signals', json(document)),
|
||||
generateSignals: () =>
|
||||
request<Pick<WorkbenchState, 'trafficSignals' | 'trafficRuntime'>>('/api/traffic-signals/generate', {
|
||||
method: 'POST',
|
||||
}),
|
||||
acceptJunctionCandidate: (index: number) =>
|
||||
request<WorkbenchState & { added: { id: string } }>('/api/junction-clusters', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ index }),
|
||||
}),
|
||||
request<WorkbenchState & { added: { id: string } }>('/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<EditStateResponse | { active: false }>('/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<EditPreviewResponse>('/api/edit-preview', json(body, signal)),
|
||||
/** Throws an `ApiError` with status 409 when another writer moved the version. */
|
||||
saveEdits: (body: SaveEditsRequest) => request<SaveEditsResponse>('/api/edits', json(body)),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user