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