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>
|
||||
);
|
||||
}
|
||||
68
workbench/client/probe/ol-ext-transform/README.md
Normal file
68
workbench/client/probe/ol-ext-transform/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# ol-ext Transform 限时探针
|
||||
|
||||
对应 `.trellis/tasks/08-26-direct-edit-map-editor/implement.md` 第 1 步。
|
||||
|
||||
这个探针只回答一个问题:**能否复用 ol-ext 通用 handle 的命中、pointer 生命周期与视觉反馈。**
|
||||
它不回答“道路语义怎么建模”——那部分由 `src/edit/` 的 `EditSession` / `projection` 负责,且已经独立单测通过。
|
||||
|
||||
## 怎么跑
|
||||
|
||||
```bash
|
||||
npm install ol-ext --no-save # 门禁通过前不进依赖清单
|
||||
npm run dev
|
||||
```
|
||||
|
||||
然后打开 <http://localhost:5173/probe/ol-ext-transform/>。
|
||||
|
||||
不需要导入 OSM,也不需要后端:页面用 `fixture.ts` 里的合成道路。
|
||||
|
||||
## 你要做的事
|
||||
|
||||
拖动地图上那个**蓝色圆点手柄**,来回拖几次,快慢都试,然后看右侧面板。
|
||||
|
||||
## 三条门禁(必须同时成立)
|
||||
|
||||
| # | 门禁 | 面板怎么读 |
|
||||
|---|---|---|
|
||||
| 1 | OL `Map` 未被重建 | “实例 #N,累计构造 N 次”两个数字必须相等。再点几次“强制 React 重渲染”,数字仍要相等。 |
|
||||
| 2 | 基线 source 未被写入 | “写入 0 次,几何未改变”。拖拽过程中这行**任何变化都算失败**。 |
|
||||
| 3 | proxy 拖拽稳定且事件能转成 draft 值 | 看**「原始位移」**那行跟手连续变化、松手不跳;手柄不粘滞、不丢命中。 |
|
||||
|
||||
门禁 3 要看的是「原始位移」,不是「约束值」。约束值带生产钳位 `±5.4 米`,
|
||||
而 5.4 米在 zoom 20 只有约 47 像素,稍微拖远就会顶到边界显示成常量——那是正常的,不代表跟踪有问题。
|
||||
「原始位移」不钳位,所以它才反映跟手质量。
|
||||
|
||||
两个数都是用生产代码算的(`signedMetersAlongAxis()` / `projectHandleValue()`),
|
||||
所以门禁 3 同时验证了“事件 → 约束值”这条链路。
|
||||
|
||||
另外面板会显示收到了多少个 `translating` 事件。这个数字和 React 渲染次数的比例是个有用的信号:
|
||||
探针已经把逐帧读数用 `requestAnimationFrame` 合并了,生产实现还要更进一步——
|
||||
ghost 直接写自己的 OL source,绝不为每次指针移动重渲染 React 树。
|
||||
|
||||
注意:**卸载/重新挂载地图会构造新的 Map,这是预期行为**,不算门禁 1 失败。门禁 1 针对的是拖拽与重渲染。
|
||||
|
||||
## 请回报给我
|
||||
|
||||
1. 三条门禁分别通过/未通过。
|
||||
2. 未通过的,面板上的具体数字或现象。
|
||||
3. 主观手感:拖拽是否顺滑,手柄命中是否可靠。
|
||||
|
||||
## 已知的、探针之外的结论
|
||||
|
||||
这些我已经查过,不用你验证,但会计入最终取舍:
|
||||
|
||||
- ol-ext 4.0.38,BSD-3-Clause,`peerDependencies: ol >= 5.3.0`,无运行时依赖。
|
||||
- **不带 TypeScript 类型,也没有 `@types/ol-ext`。** 本目录的 `ol-ext.d.ts` 是我为探针手写的最小声明;
|
||||
真要把 ol-ext 提为正式依赖,就要长期自己维护一份声明文件。
|
||||
- `Transform` 的 `translate` 分支直接对传入 feature 调 `geometry.translate()`。
|
||||
所以它只能绑一次性 proxy,绝不能绑编译产物图层——这也是门禁 2 存在的原因。
|
||||
- 它的手柄模型是 bounding box 的 scale/rotate/stretch,跟道路法线偏移、区间范围、路口 cutback 不是一回事。
|
||||
即使门禁全过,它最多承担“通用手柄的命中与拖拽生命周期”,语义投影仍然是我们自己的 `projection.ts`。
|
||||
|
||||
## 如果门禁没过
|
||||
|
||||
按 `design.md` 的回退优先级,删掉本目录,改用回退方案 1:
|
||||
原生 OL `Snap` + 小型 `PointerInteraction` adapter。
|
||||
|
||||
`HandleManifest → RoadEditOperation → RoadConstraint → preview solver` 的数据合约不变,
|
||||
所以这一步失败只换输入层,第 2 步已交付的 `EditSession` / `projection` / `meters` 全部保留。
|
||||
61
workbench/client/probe/ol-ext-transform/fixture.ts
Normal file
61
workbench/client/probe/ol-ext-transform/fixture.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// Synthetic road for the probe, so the page runs without importing OSM or
|
||||
// touching the workbench API. Geometry helpers come from the real edit modules
|
||||
// on purpose: the probe has to exercise the production conversion, not a copy.
|
||||
|
||||
import { offsetCoordinate, polylineLengthMeters, type Coordinate } from '../../src/edit/meters';
|
||||
import type { EditHandle } from '../../src/edit/types';
|
||||
|
||||
/** The road heads due north, so its normal — the drag axis — points due east. */
|
||||
export const TANGENT_AZIMUTH = 0;
|
||||
export const AXIS_AZIMUTH = 90;
|
||||
export const WIDTH_METERS = 12;
|
||||
export const LANE_COUNT = 4;
|
||||
|
||||
export const CENTERLINE: Coordinate[] = [
|
||||
[116.397, 39.905],
|
||||
[116.397, 39.915],
|
||||
];
|
||||
|
||||
export const ROAD_LENGTH_METERS = polylineLengthMeters(CENTERLINE);
|
||||
|
||||
const MIDPOINT: Coordinate = [116.397, 39.91];
|
||||
|
||||
/** Outer ring of the baseline road surface, EPSG:4326. */
|
||||
export function roadSurfaceRing(): Coordinate[] {
|
||||
const half = WIDTH_METERS / 2;
|
||||
const left = CENTERLINE.map((point) => offsetCoordinate(point, TANGENT_AZIMUTH + 90, half));
|
||||
const right = CENTERLINE.map((point) => offsetCoordinate(point, TANGENT_AZIMUTH - 90, half));
|
||||
return [...left, ...right.reverse(), left[0]];
|
||||
}
|
||||
|
||||
/** Lane divider lines, to make it obvious if the baseline ever gets rewritten. */
|
||||
export function laneDividerLines(): Coordinate[][] {
|
||||
const laneWidth = WIDTH_METERS / LANE_COUNT;
|
||||
const lines: Coordinate[][] = [];
|
||||
for (let index = 1; index < LANE_COUNT; index += 1) {
|
||||
const lateral = -WIDTH_METERS / 2 + laneWidth * index;
|
||||
lines.push(CENTERLINE.map((point) => offsetCoordinate(point, TANGENT_AZIMUTH + 90, lateral)));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* A left edge-offset handle shaped exactly like the one `makeRoadHandles()`
|
||||
* emits, so `projectHandleValue()` sees production input.
|
||||
*/
|
||||
export const PROBE_HANDLE: EditHandle = {
|
||||
handleId: 'probe:road-edge-offset:left',
|
||||
kind: 'road-edge-offset',
|
||||
anchor: {
|
||||
type: 'road-interval',
|
||||
roadId: 'probe:road/1:forward',
|
||||
startStation: 0.15,
|
||||
endStation: 0.85,
|
||||
side: 'left',
|
||||
},
|
||||
position: offsetCoordinate(MIDPOINT, AXIS_AZIMUTH, WIDTH_METERS / 2),
|
||||
axisAzimuth: AXIS_AZIMUTH,
|
||||
value: { current: 0, min: -WIDTH_METERS * 0.45, max: WIDTH_METERS * 0.45, unit: 'meter' },
|
||||
affects: [],
|
||||
editable: true,
|
||||
};
|
||||
12
workbench/client/probe/ol-ext-transform/index.html
Normal file
12
workbench/client/probe/ol-ext-transform/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>ol-ext Transform 限时探针</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="probe"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
61
workbench/client/probe/ol-ext-transform/main.tsx
Normal file
61
workbench/client/probe/ol-ext-transform/main.tsx
Normal 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>,
|
||||
);
|
||||
57
workbench/client/probe/ol-ext-transform/ol-ext.d.ts
vendored
Normal file
57
workbench/client/probe/ol-ext-transform/ol-ext.d.ts
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
// ol-ext 4.0.38 ships no TypeScript declarations and there is no @types/ol-ext.
|
||||
// This local declaration covers only what the probe touches. It deliberately
|
||||
// lives inside the probe directory: if the gate fails, deleting the directory
|
||||
// removes the typing debt with it. Promoting ol-ext to a real dependency would
|
||||
// also mean owning a real declaration file.
|
||||
|
||||
declare module 'ol-ext/interaction/Transform' {
|
||||
import type Feature from 'ol/Feature';
|
||||
import type Collection from 'ol/Collection';
|
||||
import type BaseLayer from 'ol/layer/Base';
|
||||
import PointerInteraction from 'ol/interaction/Pointer';
|
||||
|
||||
interface TransformOptions {
|
||||
layers?: BaseLayer[] | BaseLayer;
|
||||
features?: Collection<Feature>;
|
||||
filter?: (feature: Feature, layer: BaseLayer) => boolean;
|
||||
hitTolerance?: number;
|
||||
translate?: boolean;
|
||||
translateFeature?: boolean;
|
||||
translateBBox?: boolean;
|
||||
stretch?: boolean;
|
||||
scale?: boolean;
|
||||
rotate?: boolean;
|
||||
selection?: boolean;
|
||||
pointRadius?: number | number[] | ((feature: Feature) => number | number[]);
|
||||
style?: unknown;
|
||||
}
|
||||
|
||||
/** Events: select, translatestart | translating | translateend, scale*, rotate*. */
|
||||
export interface TransformEvent {
|
||||
type: string;
|
||||
feature?: Feature;
|
||||
features?: Collection<Feature>;
|
||||
/** `translating` only: [deltaX, deltaY] in map units (EPSG:3857 here). */
|
||||
delta?: [number, number];
|
||||
coordinate?: [number, number];
|
||||
pixel?: [number, number];
|
||||
/** `*end` only. */
|
||||
transformed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* ol-ext's custom event names are not in OpenLayers' event-type unions, so
|
||||
* overriding `on` / `un` on the class would clash with `Interaction` and break
|
||||
* `map.addInteraction()`. Consumers narrow through this interface instead —
|
||||
* one documented conversion rather than a cast at every listener.
|
||||
*/
|
||||
export interface TransformEvents {
|
||||
on(type: string, listener: (event: TransformEvent) => void): void;
|
||||
un(type: string, listener: (event: TransformEvent) => void): void;
|
||||
}
|
||||
|
||||
export default class Transform extends PointerInteraction {
|
||||
constructor(options?: TransformOptions);
|
||||
select(feature: Feature | null, add?: boolean): void;
|
||||
}
|
||||
}
|
||||
148
workbench/client/probe/ol-ext-transform/probe.css
Normal file
148
workbench/client/probe/ol-ext-transform/probe.css
Normal file
@@ -0,0 +1,148 @@
|
||||
/* Probe-only styles. Deliberately not sharing the workbench stylesheet, so the
|
||||
whole probe can be deleted in one directory. */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font:
|
||||
14px/1.5 system-ui,
|
||||
-apple-system,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
color: #1b2426;
|
||||
background: #f2f4f3;
|
||||
}
|
||||
.probe-nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background: #1b2426;
|
||||
color: #f2f4f3;
|
||||
}
|
||||
.probe-nav span {
|
||||
color: #9db0b3;
|
||||
font-size: 13px;
|
||||
}
|
||||
.probe-nav button {
|
||||
margin-left: auto;
|
||||
}
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #4a5a5d;
|
||||
border-radius: 6px;
|
||||
background: #f2f4f3;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
.probe {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 340px;
|
||||
height: calc(100vh - 45px);
|
||||
}
|
||||
.probe-map {
|
||||
height: 100%;
|
||||
background: #dfe4e2;
|
||||
}
|
||||
.probe-panel {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border-left: 1px solid #d3dad8;
|
||||
}
|
||||
.probe-panel h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.probe-panel p {
|
||||
margin: 0 0 12px;
|
||||
color: #59696c;
|
||||
}
|
||||
.probe-panel dt {
|
||||
margin-top: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.probe-panel dd {
|
||||
margin: 4px 0 0;
|
||||
color: #35474a;
|
||||
}
|
||||
.probe-panel ul {
|
||||
margin: 16px 0 0;
|
||||
padding-left: 18px;
|
||||
color: #59696c;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.probe-panel h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #59696c;
|
||||
}
|
||||
.probe-panel section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.probe-panel ol {
|
||||
margin: 0 0 8px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.probe-panel ol li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.probe-howto {
|
||||
padding: 12px;
|
||||
background: #eef4f2;
|
||||
border-left: 3px solid #296956;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.probe-howto p,
|
||||
.probe-ask p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.probe-live {
|
||||
padding: 12px;
|
||||
background: #1b2426;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.probe-live h2,
|
||||
.probe-live p {
|
||||
color: #9db0b3;
|
||||
}
|
||||
.probe-big {
|
||||
display: block;
|
||||
margin: 2px 0 6px;
|
||||
font-size: 30px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #4ad2f0;
|
||||
}
|
||||
.probe-ask {
|
||||
padding: 12px;
|
||||
background: #fdf6e8;
|
||||
border-left: 3px solid #d49318;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.probe-empty {
|
||||
padding: 24px;
|
||||
}
|
||||
.probe-error {
|
||||
padding: 24px;
|
||||
max-width: 900px;
|
||||
}
|
||||
.probe-error h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
color: #bf3b2e;
|
||||
}
|
||||
.probe-error pre {
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
background: #1b2426;
|
||||
color: #f2f4f3;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
7
workbench/client/probe/ol-ext-transform/tsconfig.json
Normal file
7
workbench/client/probe/ol-ext-transform/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [".", "../../src/edit"]
|
||||
}
|
||||
Reference in New Issue
Block a user