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:
2026-08-27 14:59:11 +08:00
parent e25c564bb9
commit bc4b9a9717
25 changed files with 2463 additions and 22 deletions

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import { disabledReasonOf, handlesForSegment, reservesForSegment } from './selection';
import type { ConstraintKind, EditHandle, HandleManifest, SemanticAnchor } from './types';
function handle(handleId: string, kind: ConstraintKind, anchor: SemanticAnchor, editable = true): EditHandle {
return {
handleId,
kind,
anchor,
position: [116.397, 39.908],
axisAzimuth: 90,
value: { current: 0, min: -5, max: 5, unit: 'meter' },
affects: [],
editable,
...(editable ? {} : { disabledReason: '该道路全部位于路口保留区,请进入 JunctionTools 编辑。' }),
};
}
const roadInterval = (roadId: string): SemanticAnchor => ({
type: 'road-interval',
roadId,
startStation: 0.15,
endStation: 0.85,
side: 'left',
});
// Two segments, plus a junction handle the main map must never render.
const manifest: HandleManifest = {
schema: 'road-edit-handles/v1',
revisionId: 'rev-0001',
previewSeq: 0,
handles: [
handle('h:edge:a', 'road-edge-offset', roadInterval('road:way/1:forward')),
handle('h:sidewalk:a', 'road-sidewalk-width', roadInterval('road:way/1:forward')),
handle('h:edge:b', 'road-edge-offset', roadInterval('road:way/2:forward')),
handle('h:blocked:a', 'road-lane-divider', roadInterval('road:way/1:forward'), false),
handle('h:approach', 'junction-approach-width', { type: 'junction-approach', nodeId: '9', segmentId: 'seg:1' }),
handle('h:corner', 'junction-corner-radius', {
type: 'junction-corner',
nodeId: '9',
incomingRoadId: 'seg:1',
outgoingRoadId: 'seg:2',
}),
],
reserves: [
{ nodeId: '9', roadId: 'seg:1', fromStation: 0, toStation: 0.15 },
{ nodeId: '10', roadId: 'seg:1', fromStation: 0.85, toStation: 1 },
{ nodeId: '11', roadId: 'seg:2', fromStation: 0, toStation: 0.3 },
],
};
const segmentOf = (roadId: string) =>
({ 'road:way/1:forward': 'seg:1', 'road:way/2:forward': 'seg:2' })[roadId] ?? undefined;
describe('handlesForSegment', () => {
it('keeps only the selected segment', () => {
expect(handlesForSegment(manifest, 'seg:1', segmentOf).map((item) => item.handleId)).toEqual([
'h:edge:a',
'h:sidewalk:a',
'h:blocked:a',
]);
expect(handlesForSegment(manifest, 'seg:2', segmentOf).map((item) => item.handleId)).toEqual(['h:edge:b']);
});
it('never renders junction kinds on the main map', () => {
// Reserve interiors belong to JunctionTools; offering a second way to edit
// them here is exactly the ownership split design.md forbids.
const kinds = handlesForSegment(manifest, 'seg:1', segmentOf).map((item) => item.kind);
expect(kinds).not.toContain('junction-approach-width');
expect(kinds).not.toContain('junction-corner-radius');
});
it('keeps disabled handles so the boundary stays explainable', () => {
const blocked = handlesForSegment(manifest, 'seg:1', segmentOf).find((item) => !item.editable);
expect(blocked?.handleId).toBe('h:blocked:a');
});
it('returns nothing without a manifest or a selection', () => {
expect(handlesForSegment(null, 'seg:1', segmentOf)).toEqual([]);
expect(handlesForSegment(manifest, null, segmentOf)).toEqual([]);
});
it('drops handles whose road no longer resolves to a segment', () => {
expect(handlesForSegment(manifest, 'seg:1', () => undefined)).toEqual([]);
});
});
describe('reservesForSegment', () => {
it('returns only that segments reserves', () => {
expect(reservesForSegment(manifest, 'seg:1').map((item) => item.nodeId)).toEqual(['9', '10']);
expect(reservesForSegment(manifest, 'seg:2').map((item) => item.nodeId)).toEqual(['11']);
});
it('returns nothing without a manifest or a selection', () => {
expect(reservesForSegment(null, 'seg:1')).toEqual([]);
expect(reservesForSegment(manifest, null)).toEqual([]);
});
});
describe('disabledReasonOf', () => {
it('says nothing for an editable handle', () => {
expect(disabledReasonOf(handle('h', 'road-edge-offset', roadInterval('road:way/1:forward')))).toBeUndefined();
});
it('passes the server reason through', () => {
const blocked = handle('h', 'road-edge-offset', roadInterval('road:way/1:forward'), false);
expect(disabledReasonOf(blocked)).toContain('JunctionTools');
});
it('always gives some reason, even when the server omitted one', () => {
const blocked = { ...handle('h', 'road-edge-offset', roadInterval('road:way/1:forward'), false) };
delete blocked.disabledReason;
expect(disabledReasonOf(blocked)).toContain('路口保留区');
});
});