// 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(null); const renders = useRef(0); const [report, setReport] = useState(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 (
); }