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"]
}