feat: drag road handles with ghost and server preview

Three things, kept in one commit because they touch overlapping hunks of the
same two files and this environment has no interactive hunk staging. Splitting
them by file would have drawn boundaries that misrepresent what changed.

1. Step 4 of the map editor. A native OpenLayers PointerInteraction turns a
   drag into a clamped constraint value, the ghost source shows it immediately,
   and the solver's answer replaces a parallel set of preview layers while the
   baseline layers are hidden rather than overwritten. Preview requests debounce
   at 80 ms, pointerup flushes without waiting, and a newer request aborts the
   one in flight; EditSession decides which answers count. Handle positions come
   from the clamped value, so a handle stops at its limit instead of following
   the cursor. Three of the four drag capabilities are live: edge offset,
   sidewalk width, lane divider.

2. Road edge handles were drawn on the wrong side. offsetLine() offsets
   counter-clockwise from the direction of travel and sidewalks use
   `heading + (side === 'left' ? -90 : 90)`, so left is `tangent - 90`;
   makeRoadHandles() placed the left handle at `tangent + 90`, over the right
   kerb. Dragging the visually-left handle moved the right edge. Fixed on both
   sides of the wire, with regression tests that name the sides geographically
   rather than by axis sign.

3. Roads the junctions geometrically fill are now read-only. The 0.45 cap per
   reserve made the existing `unavailable` branch unreachable, so a 14.5 m stub
   between two junctions was offered a 1.5 m editable band with no room for the
   transitions a road-interval constraint needs. Greying only affects the
   manifest: constraints already saved against such a road keep being solved, so
   the geometry output is unchanged and the fixture baselines do not move.

Range handles are built and unit-tested but hidden behind
`intervalEditingSupported`: compileGeometry() reads neither profile.interval nor
profile.transitions, so every edit applies to the whole road and the control
would have had no effect. Recorded in research/interval-not-applied.md, which
also blocks one PRD acceptance criterion.

The ol-ext probe stays in the tree as a manual harness; ol-ext is still not a
dependency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 17:38:34 +08:00
parent bc4b9a9717
commit 92297270f1
25 changed files with 1826 additions and 58 deletions

View File

@@ -86,6 +86,27 @@ assert.ok(junctionResolution.handles.handles.every((handle) => handle.position.e
assert.ok(junctionResolution.handles.handles.every((handle) => Number.isFinite(handle.axisAzimuth)));
assert.ok(junctionResolution.handles.handles.some((handle) => handle.affects.includes('junction:node/junction')));
// The left handle must sit on the geometry compiler's left. `offsetLine()` shifts a
// positive offset counter-clockwise from the direction of travel and sidewalks use
// `heading + (side === 'left' ? -90 : 90)`, so for the north-heading road:a that is
// west. Drawing it east put it over the right kerb, and dragging the visually-left
// handle then moved the right edge.
const northEdges = junctionResolution.handles.handles.filter(
(handle) => handle.kind === 'road-edge-offset' && handle.anchor.roadId === 'road:a',
);
const leftEdge = northEdges.find((handle) => handle.anchor.side === 'left');
const rightEdge = northEdges.find((handle) => handle.anchor.side === 'right');
assert.ok(leftEdge && rightEdge, 'both edge handles must be published');
assert.ok(leftEdge.position[0] < 113, 'left edge handle must sit west of a north-heading centerline');
assert.ok(rightEdge.position[0] > 113, 'right edge handle must sit east of a north-heading centerline');
const northSidewalks = junctionResolution.handles.handles.filter(
(handle) => handle.kind === 'road-sidewalk-width' && handle.anchor.roadId === 'road:a',
);
assert.ok(
northSidewalks.find((handle) => handle.anchor.side === 'left').position[0] < 113,
'the sidewalk handle must follow the same side convention as the edge handle',
);
const anchored = constraint({
id: 'edge-on-road-a',
anchor: { type: 'road-interval', roadId: 'road:a', startStation: 0.2, endStation: 0.8, side: 'left' },
@@ -228,6 +249,109 @@ assert.deepEqual(
['a', 'b', 'c'],
);
// A short road sandwiched between two junctions. `junctionReserves()` caps each
// end's reserve at 0.45, so `start >= end` can never fire; what decides whether
// an edit is offered is the surviving band measured in meters against the road's
// own width. 0.00013 degrees of latitude is about 14.4 m, shorter than the
// cutback the approaches demand at either end, so both ends hit the cap and the
// band lands at ~1.4 m.
function sandwichModel(shortWidthMeters, shortLaneCount) {
const nodeA = [113, 30];
const nodeB = [113, 30.00013];
const road = (id, from, to, widthMeters, laneCount) => ({
id: `road:${id}`,
segmentId: `segment:${id}`,
direction: 'forward',
centerline: [from, to],
sourceNodeIds: [`node-${id}-a`, `node-${id}-b`],
osmWayIds: [id],
widthMeters,
laneCount,
sidewalkLeft: true,
sidewalkRight: true,
});
const endpoint = (roadId, side, nodeId, coordinate) => ({
id: `endpoint:${roadId}:${side}`,
roadId,
side,
nodeId,
coordinate,
});
return {
roads: [
road('short', nodeA, nodeB, shortWidthMeters, shortLaneCount),
road('a1', nodeA, [112.999, 30], 6, 2),
road('a2', nodeA, [113.001, 30], 6, 2),
road('b1', nodeB, [112.999, 30.00013], 6, 2),
road('b2', nodeB, [113.001, 30.00013], 6, 2),
],
endpoints: [
endpoint('road:short', 'start', 'node-a', nodeA),
endpoint('road:a1', 'start', 'node-a', nodeA),
endpoint('road:a2', 'start', 'node-a', nodeA),
endpoint('road:short', 'end', 'node-b', nodeB),
endpoint('road:b1', 'start', 'node-b', nodeB),
endpoint('road:b2', 'start', 'node-b', nodeB),
],
connections: [],
};
}
function roadHandlesFor(resolution, roadId) {
return resolution.handles.handles.filter(
(handle) => handle.kind.startsWith('road-') && handle.anchor.roadId === roadId,
);
}
// The short road's band is ~1.4 m against a 10 m width, so every road handle on
// it is read-only and says why. The long approach at the same junction keeps its
// handles, which is what proves the rule is about the band and not about being
// adjacent to a junction.
const sandwich = resolveDirectEditConstraints(sandwichModel(10, 4), document([]), { revisionId: 'rev-sandwich' });
const shortHandles = roadHandlesFor(sandwich, 'road:short');
const longHandles = roadHandlesFor(sandwich, 'road:a1');
assert.ok(shortHandles.length > 0, 'the blocked road must still publish handles so the map can explain them');
assert.ok(longHandles.length > 0);
assert.ok(shortHandles.every((handle) => handle.editable === false));
assert.ok(shortHandles.every((handle) => handle.disabledReason.includes('JunctionTools')));
assert.ok(longHandles.every((handle) => handle.editable === true));
assert.ok(longHandles.every((handle) => handle.disabledReason === undefined));
// Junction kinds are JunctionTools' territory and must stay draggable there.
assert.ok(
sandwich.handles.handles
.filter((handle) => handle.kind.startsWith('junction-'))
.every((handle) => handle.editable === true),
);
// The band is compared in meters against the road's own width. The 0.45 cap keeps
// the band at ~1.4 m either way, so changing only the width crosses the boundary.
assert.ok(
roadHandlesFor(resolveDirectEditConstraints(sandwichModel(2, 2), document([])), 'road:short').every(
(h) => !h.editable,
),
);
assert.ok(
roadHandlesFor(resolveDirectEditConstraints(sandwichModel(1, 2), document([])), 'road:short').every(
(h) => h.editable,
),
);
// Greying is a UI affordance, not a retroactive veto: an edit already saved
// against this road keeps being solved, or a compiler upgrade would silently drop
// it and the geometry would move underneath the user.
const onBlocked = resolveDirectEditConstraints(
sandwichModel(10, 4),
document([
constraint({
id: 'edge-on-short',
anchor: { type: 'road-interval', roadId: 'road:short', startStation: 0.45, endStation: 0.55, side: 'left' },
}),
]),
);
assert.equal(onBlocked.constraintStates[0].applied, true);
assert.equal(onBlocked.roadProfiles.get('road:short').edgeOffsets.left, 1.5);
// The solver must be free of file and network access so preview can share it.
const source = require('fs').readFileSync(require.resolve('../src/compile/direct-edit-solver'), 'utf8');
for (const forbidden of ["require('fs')", "require('path')", "require('http')", "require('https')"])