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>
270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
// 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>
|
||
);
|
||
}
|