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:
2026-08-27 14:59:11 +08:00
parent e25c564bb9
commit bc4b9a9717
25 changed files with 2463 additions and 22 deletions

View 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>
);
}

View 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.38BSD-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` 全部保留。

View 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,
};

View 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>

View 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>,
);

View 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;
}
}

View 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;
}

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": [".", "../../src/edit"]
}

View File

@@ -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}

View File

@@ -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;

View 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();

View 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;
}
}

View 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);
});
});

View 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));
}

View 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);
});
});

View 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 } : {}),
},
};
}

View 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 segments 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('路口保留区');
});
});

View 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 编辑。';
}

View 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);
});
});

View 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 };
}
}

View 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';
}

View File

@@ -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)),
};