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