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:
269
workbench/client/probe/ol-ext-transform/ProbeMap.tsx
Normal file
269
workbench/client/probe/ol-ext-transform/ProbeMap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user