Files
road-compiler/workbench/client/probe/ol-ext-transform/ProbeMap.tsx
que01 bc4b9a9717 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>
2026-08-27 14:59:11 +08:00

270 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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>
);
}