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

@@ -1,12 +1,23 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Download, RefreshCw, Save, Upload } from 'lucide-react';
import { api } from './lib/api';
import { MapCanvas } from './components/MapCanvas';
import { directEditEnabled } from './edit/flag';
import { disabledReasonOf, handlesForSegment } from './edit/selection';
import type { HandleManifest } from './edit/types';
import { directEditEnabled, intervalEditingSupported } from './edit/flag';
import { PreviewRequester, type PreviewDraft } from './edit/preview-request';
import { anchorSnapshotFor, constraintValueFor, draftConstraint, operationFor } from './edit/projection';
import { disabledReasonOf, handlesForSegment, intervalRangeHandles, type IntervalRangeHandle } from './edit/selection';
import { EditSession } from './edit/session';
import type {
EditDiagnostic,
EditHandle,
HandleManifest,
PreviewLayerName,
RoadConstraint,
RoadEditOperation,
RoadIntervalAnchor,
} from './edit/types';
import { Button } from './ui/button';
import type { GeoFeature, Override, Road, WorkbenchState } from './types/state';
import type { GeoFeature, GeoJson, Override, Road, WorkbenchState } from './types/state';
import type { LayerName } from './map/layers';
const layerLabels: Array<[LayerName, string]> = [
@@ -99,12 +110,21 @@ function App() {
api
.editState()
.then((value) => {
if ('handles' in value) setManifest(value.handles);
if (!('handles' in value)) return;
setManifest(value.handles);
// A drag drafts on top of whatever is already saved, so keep the active
// document's constraints rather than sending a lone constraint.
savedConstraints.current = value.document.constraints;
savedOperations.current = value.document.operations;
})
// A manifest failure must not take the workbench down with it; the map just
// shows no handles.
.catch((error: Error) => setStatus(`直接编辑手柄不可用:${error.message}`));
}, []);
// Keyed on `state`, not mounted once: on a fresh import the first attempt runs
// before any area exists and comes back inactive, so the handles never appeared
// until a manual reload. A recompile also moves the geometry the manifest
// describes, so the manifest has to be refetched with it.
}, [state]);
const editHandles = useMemo(() => {
if (!directEditEnabled || !state) return [];
// Handle anchors carry a directional road id while reserves are keyed by
@@ -116,6 +136,96 @@ function App() {
// boundary, so the first blocked handle explains itself in the header.
const blockedHandle = editHandles.find((handle) => !handle.editable);
const blockedReason = blockedHandle ? disabledReasonOf(blockedHandle) : undefined;
// Every road handle on a segment shares one interval — `makeRoadHandles()` builds
// it once per segment — so one pair of range ends covers the whole selection.
const editRanges = useMemo(() => {
// Hidden until compileGeometry() honours profile.interval; see flag.ts.
if (!directEditEnabled || !intervalEditingSupported || !state || !selected || !manifest) return [];
const parent = editHandles.find((handle) => handle.editable && handle.anchor.type === 'road-interval');
const roadId = parent && 'roadId' in parent.anchor ? parent.anchor.roadId : null;
const road = roadId ? state.compiled.model.roads.find((item) => item.id === roadId) : undefined;
if (!parent || !road) return [];
return intervalRangeHandles(parent, road.centerline, manifest.reserves, selected.segmentId);
}, [editHandles, manifest, selected, state]);
const [preview, setPreview] = useState<Partial<Record<PreviewLayerName, GeoJson | null>> | null>(null);
const [editDiagnostics, setEditDiagnostics] = useState<EditDiagnostic[]>([]);
// The working set: the saved document plus whatever this session has drafted.
// Advanced on pointerup, not on every move, so an abandoned drag leaves nothing
// behind — and so a later range drag can re-anchor the edit a value drag made.
const savedConstraints = useRef<RoadConstraint[]>([]);
/** Their operations. A constraint without its operation is rejected as a 400. */
const savedOperations = useRef<RoadEditOperation[]>([]);
const sessionRef = useRef<EditSession | null>(null);
if (directEditEnabled && !sessionRef.current) sessionRef.current = new EditSession();
const requester = useMemo(() => {
const session = sessionRef.current;
if (!session) return null;
return new PreviewRequester({
session,
send: (request, signal) => api.editPreview(request, signal),
onSettled: (outcome, response) => {
setEditDiagnostics(response.diagnostics);
// The response carries a re-solved manifest. Keeping the stale one made the
// handles snap back to their pre-drag positions the moment the ghost cleared.
setManifest(response.handles);
// A rejected draft leaves the last valid preview on screen; the session
// has already decided that, so this only mirrors its verdict.
if (!outcome.blocked) setPreview(response.layers);
},
onError: (error) => setStatus(`预览失败:${(error as Error).message}`),
});
}, []);
/** A drag becomes one drafted constraint layered over the saved document. */
const draftFor = (handle: EditHandle, value: number): PreviewDraft | null => {
const roadId = 'roadId' in handle.anchor ? handle.anchor.roadId : null;
const road = roadId ? state?.compiled.model.roads.find((item) => item.id === roadId) : undefined;
// Without a centerline there is no snapshot to record, so drop the drag rather
// than send a constraint the server would have to reject.
if (!road || road.centerline.length < 2) return null;
const constraint = draftConstraint(
handle,
handle.anchor,
constraintValueFor(handle, value),
anchorSnapshotFor(handle, road.centerline, road.sourceNodeIds),
{
constraintId: `constraint:${handle.handleId}`,
operationId: `operation:${handle.handleId}`,
createdAt: new Date().toISOString(),
},
);
const operation = operationFor(constraint);
// Both halves travel: validateEditDocument() rejects a constraint whose
// provenance.operationId is not a recorded operation, and that check covers the
// already-saved constraints too, not just the one being dragged.
return {
constraints: [...savedConstraints.current.filter((item) => item.id !== constraint.id), constraint],
operations: [...savedOperations.current.filter((item) => item.id !== operation.id), operation],
};
};
/**
* A range drag re-anchors the constraints already on this road rather than
* creating one: the interval is a property of the road's edit, shared by every
* constraint on it. With no constraint yet there is nothing to re-anchor.
*/
const rangeDraftFor = (range: IntervalRangeHandle, anchor: RoadIntervalAnchor): PreviewDraft | null => {
const onRoad = (item: RoadConstraint) =>
item.anchor.type === 'road-interval' && item.anchor.roadId === range.roadId;
if (!savedConstraints.current.some(onRoad)) return null;
return {
constraints: savedConstraints.current.map((item) =>
onRoad(item)
? { ...item, anchor: { ...item.anchor, startStation: anchor.startStation, endStation: anchor.endStation } }
: item,
),
operations: savedOperations.current,
};
};
/** Both drag kinds commit their result here so the next drag builds on it. */
const advance = (draft: PreviewDraft | null) => {
if (!draft) return;
savedConstraints.current = draft.constraints ?? savedConstraints.current;
savedOperations.current = draft.operations ?? savedOperations.current;
};
const stage = (change: Override) =>
setStaged((current) => [...current.filter((item) => item.id !== change.id), change]);
const save = async () => {
@@ -180,12 +290,13 @@ function App() {
{staged.length ? `未保存修改 ${staged.length}` : '所有修改已保存'}
</span>
{directEditEnabled ? (
<span>
{editHandles.length
? `手柄 ${editHandles.length}`
: selected
? '当前道路无可编辑手柄'
: '选中道路以显示手柄'}
<span className={editDiagnostics.some((item) => item.severity === 'error') ? 'dirty' : ''}>
{editDiagnostics.find((item) => item.severity === 'error')?.message ||
(editHandles.length
? `手柄 ${editHandles.length}`
: selected
? '当前道路无可编辑手柄'
: '选中道路以显示手柄')}
{blockedReason ? `${blockedReason}` : ''}
</span>
) : null}
@@ -251,6 +362,32 @@ function App() {
onSelectRoad={setSelected}
onFeature={handleFeature}
handles={editHandles}
ranges={editRanges}
preview={preview}
onHandleDrag={(handle, value) => {
const draft = draftFor(handle, value);
if (draft) requester?.schedule(draft);
}}
onHandleDragEnd={(handle, value) => {
const draft = draftFor(handle, value);
if (!draft) return;
requester?.flush(draft);
advance(draft);
}}
onHandleBlocked={(_handle, reason) => setStatus(reason)}
onRangeDrag={(range, anchor) => {
const draft = rangeDraftFor(range, anchor);
if (draft) requester?.schedule(draft);
}}
onRangeEnd={(range, anchor) => {
const draft = rangeDraftFor(range, anchor);
if (!draft) {
setStatus('先拖动路缘、步行带或车道分隔手柄产生一次编辑,范围手柄才有可调整的区间。');
return;
}
requester?.flush(draft);
advance(draft);
}}
/>
<Inspector
road={selected}

View File

@@ -3,11 +3,15 @@ import Map from 'ol/Map';
import View from 'ol/View';
import Select from 'ol/interaction/Select';
import { click } from 'ol/events/condition';
import type { Road, WorkbenchState } from '../types/state';
import type { GeoJson, Road, WorkbenchState } from '../types/state';
import { createLayers, updateLayers, updateSelectedRoad, type LayerName } from '../map/layers';
import { directEditEnabled } from '../edit/flag';
import { createHandleDragInteraction } from '../edit/drag-interaction';
import { EditGhostLayer } from '../edit/ghost-layer';
import { EditHandleLayer } from '../edit/handle-layer';
import type { EditHandle } from '../edit/types';
import { EditPreviewLayer } from '../edit/preview-layer';
import type { IntervalRangeHandle } from '../edit/selection';
import type { EditHandle, PreviewLayerName, RoadIntervalAnchor } from '../edit/types';
interface Props {
state: WorkbenchState;
@@ -18,12 +22,47 @@ interface Props {
onFeature: (properties: Record<string, unknown>) => void;
/** Already filtered to the selected road. Ignored unless `directEdit` is on. */
handles?: EditHandle[];
/** The two ends of the affected interval, drawn as squares. */
ranges?: IntervalRangeHandle[];
/** Authoritative preview geometry; null returns the map to the baseline. */
preview?: Partial<Record<PreviewLayerName, GeoJson | null>> | null;
/** Live value during a drag — the caller debounces the preview request. */
onHandleDrag?: (handle: EditHandle, value: number) => void;
/** Final value on pointerup — the caller sends it without debouncing. */
onHandleDragEnd?: (handle: EditHandle, value: number) => void;
onHandleBlocked?: (handle: EditHandle, reason: string) => void;
/** Live interval while a range end is dragged. */
onRangeDrag?: (range: IntervalRangeHandle, anchor: RoadIntervalAnchor) => void;
onRangeEnd?: (range: IntervalRangeHandle, anchor: RoadIntervalAnchor) => void;
}
export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFeature, handles }: Props) {
export function MapCanvas({
state,
selected,
visible,
scene,
onSelectRoad,
onFeature,
handles,
ranges,
preview,
onHandleDrag,
onHandleDragEnd,
onHandleBlocked,
onRangeDrag,
onRangeEnd,
}: Props) {
const target = useRef<HTMLDivElement>(null);
const mapRef = useRef<Map | null>(null);
const layersRef = useRef<ReturnType<typeof createLayers> | null>(null);
const handleLayerRef = useRef<EditHandleLayer | null>(null);
const ghostRef = useRef<EditGhostLayer | null>(null);
const previewRef = useRef<EditPreviewLayer | null>(null);
const visibleRef = useRef(visible);
visibleRef.current = visible;
// Callbacks live in refs so the map is built once and never rebuilt when a
// parent re-renders with new closures.
const dragRef = useRef({ onHandleDrag, onHandleDragEnd, onHandleBlocked, onRangeDrag, onRangeEnd });
dragRef.current = { onHandleDrag, onHandleDragEnd, onHandleBlocked, onRangeDrag, onRangeEnd };
const selectedRef = useRef<Road | null>(selected);
const stateRef = useRef(state);
const sceneRef = useRef(scene);
@@ -40,10 +79,24 @@ export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFea
// With `directEdit` off nothing below exists: no extra source, no layer, no
// interaction — the canvas is byte-for-byte the shipped behaviour.
const handleLayer = directEditEnabled ? new EditHandleLayer() : null;
const ghost = directEditEnabled ? new EditGhostLayer() : null;
const previewLayer = directEditEnabled
? new EditPreviewLayer(
() => selectedRef.current,
() => sceneRef.current,
)
: null;
handleLayerRef.current = handleLayer;
ghostRef.current = ghost;
previewRef.current = previewLayer;
const map = new Map({
target: target.current,
layers: handleLayer ? [...Object.values(layers), handleLayer.layer] : Object.values(layers),
layers: [
...Object.values(layers),
...(previewLayer ? previewLayer.all() : []),
...(handleLayer ? [handleLayer.layer] : []),
...(ghost ? [ghost.layer] : []),
],
view: new View({ center: [0, 0], zoom: 2 }),
});
mapRef.current = map;
@@ -56,6 +109,21 @@ export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFea
style: null,
});
map.addInteraction(select);
if (handleLayer && ghost) {
map.addInteraction(
createHandleDragInteraction({
layer: handleLayer.layer,
ghost,
handleAt: (handleId) => handleLayer.handle(handleId),
rangeAt: (handleId) => handleLayer.range(handleId),
onDrag: (handle, value) => dragRef.current.onHandleDrag?.(handle, value),
onEnd: (handle, value) => dragRef.current.onHandleDragEnd?.(handle, value),
onRangeDrag: (range, anchor) => dragRef.current.onRangeDrag?.(range, anchor),
onRangeEnd: (range, anchor) => dragRef.current.onRangeEnd?.(range, anchor),
onBlocked: (handle, reason) => dragRef.current.onHandleBlocked?.(handle, reason),
}),
);
}
const listener = ({ selected: values }: { selected: import('ol/Feature').default[] }) => {
const properties = values[0]?.getProperties();
if (!properties) return;
@@ -94,8 +162,17 @@ export function MapCanvas({ state, selected, visible, scene, onSelectRoad, onFea
useEffect(() => {
// Null when the flag is off, so this whole path is inert on main.
// Only the editHandles source is replaced; baseline layers are never touched.
handleLayerRef.current?.render(handles ?? []);
}, [handles]);
handleLayerRef.current?.render(handles ?? [], ranges ?? []);
}, [handles, ranges]);
useEffect(() => {
const previewLayer = previewRef.current;
const baseline = layersRef.current;
if (!previewLayer || !baseline) return;
// Showing hides the baseline layers it supersedes; clearing gives them back
// the visibility the layer switches ask for, never a hardcoded default.
if (preview) previewLayer.show(preview, baseline);
else previewLayer.clear(baseline, visibleRef.current);
}, [preview]);
useEffect(() => {
const layers = layersRef.current;
if (!layers) return;

View File

@@ -0,0 +1,133 @@
// Dragging a handle, on native OpenLayers.
//
// Two things the ol-ext probe settled are baked in here:
//
// 1. The handle position is derived from the clamped constraint value, never from
// the raw pointer delta. `Transform` translated its proxy by the delta, so a
// drag reading -24.1 m left the handle 24 m out while the constraint clamped at
// -5.4 m. `EditGhostLayer.show()` takes the value, not the cursor.
// 2. Hit-testing happens once, on pointerdown. ol-ext ran `forEachFeatureAtPixel`
// from `handleMoveEvent_`, i.e. a canvas readback every mousemove. A drag only
// needs to know what it grabbed at the start.
//
// Two kinds of handle share the gesture: a value handle moves the constraint's
// number, a range end moves the interval its anchor covers. They are branched here
// rather than in two interactions so only one of them can ever own a pointer.
//
// Baseline layers are never touched: this reads the handle layer and writes only
// to the ghost source.
import PointerInteraction from 'ol/interaction/Pointer';
import type MapBrowserEvent from 'ol/MapBrowserEvent';
import type VectorLayer from 'ol/layer/Vector';
import type VectorSource from 'ol/source/Vector';
import type { EditGhostLayer } from './ghost-layer';
import { HANDLE_ID } from './handle-layer';
import type { Coordinate } from './meters';
import { projectHandleValue, projectIntervalEnd } from './projection';
import type { IntervalRangeHandle } from './selection';
import type { EditHandle, RoadIntervalAnchor } from './types';
/**
* OpenLayers hands every pointer handler the same widened event: the union covers
* keyboard and wheel because `PointerInteraction` shares one dispatch path. The
* fields used here — `map`, `pixel`, `coordinate` — exist on all of them.
*/
type MapPointerEvent = MapBrowserEvent<PointerEvent | KeyboardEvent | WheelEvent>;
type Active =
| { kind: 'value'; handle: EditHandle; origin: Coordinate }
| { kind: 'range'; range: IntervalRangeHandle; origin: Coordinate };
export interface HandleDragOptions {
/** The `editHandles` layer, the only layer hit-tested. */
layer: VectorLayer<VectorSource>;
ghost: EditGhostLayer;
/** Manifest lookup by handle id; handles carry nothing but their id. */
handleAt: (handleId: string) => EditHandle | undefined;
/** Range-end lookup, for the anchor-moving handles. */
rangeAt: (handleId: string) => IntervalRangeHandle | undefined;
/** Live value during a value drag. Callers debounce the preview request. */
onDrag: (handle: EditHandle, value: number) => void;
/** Final value on pointerup. Callers send this one without debouncing. */
onEnd: (handle: EditHandle, value: number) => void;
/** Live interval during a range drag. */
onRangeDrag: (range: IntervalRangeHandle, anchor: RoadIntervalAnchor) => void;
onRangeEnd: (range: IntervalRangeHandle, anchor: RoadIntervalAnchor) => void;
/** A reserve handle refused the drag, with the manifest's reason. */
onBlocked?: (handle: EditHandle, reason: string) => void;
}
export function createHandleDragInteraction(options: HandleDragOptions): PointerInteraction {
let active: Active | null = null;
const intervalAt = (state: Extract<Active, { kind: 'range' }>, to: Coordinate) =>
projectIntervalEnd(
state.range.anchor,
state.range.end,
state.origin,
to,
state.range.tangentAzimuth,
state.range.roadLengthMeters,
state.range.window,
);
return new PointerInteraction({
handleDownEvent: (event: MapPointerEvent) => {
const feature = event.map.forEachFeatureAtPixel(event.pixel, (candidate) => candidate, {
layerFilter: (layer) => layer === options.layer,
hitTolerance: 10,
});
if (!feature) return false;
const id = String(feature.get(HANDLE_ID));
const origin = event.coordinate as Coordinate;
const range = options.rangeAt(id);
if (range) {
active = { kind: 'range', range, origin };
options.ghost.showInterval(range, range.anchor);
return true;
}
const handle = options.handleAt(id);
if (!handle) return false;
if (!handle.editable) {
// Explain, then decline the gesture so the map still pans. Swallowing it
// would make a reserve handle feel broken rather than owned elsewhere.
options.onBlocked?.(handle, handle.disabledReason || '该手柄不可拖动。');
return false;
}
active = { kind: 'value', handle, origin };
options.ghost.show(handle, handle.value.current);
return true;
},
handleDragEvent: (event: MapPointerEvent) => {
const state = active;
if (!state) return;
const to = event.coordinate as Coordinate;
if (state.kind === 'range') {
const anchor = intervalAt(state, to);
options.ghost.showInterval(state.range, anchor);
options.onRangeDrag(state.range, anchor);
return;
}
const value = projectHandleValue(state.handle, state.origin, to);
options.ghost.show(state.handle, value);
options.onDrag(state.handle, value);
},
handleUpEvent: (event: MapPointerEvent) => {
const state = active;
active = null;
if (!state) return false;
const to = event.coordinate as Coordinate;
// The final request is not debounced: whatever the pointer settled on is the
// value the user meant, and it must not be dropped by a pending timer.
if (state.kind === 'range') options.onRangeEnd(state.range, intervalAt(state, to));
else options.onEnd(state.handle, projectHandleValue(state.handle, state.origin, to));
options.ghost.clear();
return false;
},
});
}

View File

@@ -29,3 +29,18 @@ function readFlag(): boolean {
* halfway through and build a half-wired map.
*/
export const directEditEnabled = readFlag();
/**
* Whether a `road-interval` constraint actually applies to its interval.
*
* It does not yet. The solver builds `profile.interval` and `profile.transitions`
* (direct-edit-solver.js:274, :418) but `compileGeometry()` reads neither — grep
* `native-road.js` for `.interval` and it comes back empty. Every direct edit is
* therefore applied to the whole road.
*
* So the range handles are hidden: `intervalRangeHandles()` and
* `projectIntervalEnd()` are correct and unit-tested, but a control whose drag
* changes nothing is worse than no control. Flip this to `true` in the same commit
* that makes the geometry stage honour the interval.
*/
export const intervalEditingSupported = false;

View File

@@ -0,0 +1,125 @@
// The `editGhost` source: client-side immediate feedback while a drag is live.
//
// Written to directly, never through React state. The ol-ext probe drove its
// readout from a setState per `translating` event and produced ~1600 renders in
// one session; React is the wrong owner of per-frame feedback. The ghost is also
// not authoritative — it shows where the handle now sits and how far it moved,
// while `editPreview` carries the geometry the server actually solved.
import Feature from 'ol/Feature';
import LineString from 'ol/geom/LineString';
import Point from 'ol/geom/Point';
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 Text from 'ol/style/Text';
import { coordinateAtStation, fromLonLat, type Coordinate } from './meters';
import { handlePositionFor } from './projection';
import type { IntervalRangeHandle } from './selection';
import type { EditHandle, RoadIntervalAnchor } from './types';
const GUIDE = new Style({
stroke: new Stroke({ color: '#00a5cf', width: 2, lineDash: [6, 4] }),
});
/** The stretch of road an interval edit applies to. */
const BAND = new Style({
stroke: new Stroke({ color: '#29695699', width: 14 }),
});
/** Drawn at the bound so it is obvious the handle stopped rather than stuck. */
const CLAMPED = new Style({
stroke: new Stroke({ color: '#d49318', width: 2, lineDash: [6, 4] }),
});
function knob(label: string, clamped: boolean): Style {
return new Style({
image: new CircleStyle({
radius: 8,
fill: new Fill({ color: clamped ? '#d49318' : '#00a5cf' }),
stroke: new Stroke({ color: '#fff', width: 2 }),
}),
text: new Text({
text: label,
offsetY: -18,
font: '600 12px system-ui, sans-serif',
fill: new Fill({ color: '#1b2426' }),
stroke: new Stroke({ color: '#ffffffcc', width: 3 }),
}),
});
}
export class EditGhostLayer {
readonly layer: VectorLayer<VectorSource>;
private readonly source = new VectorSource();
constructor() {
this.layer = new VectorLayer({
source: this.source,
// Above the handles so the live knob is never occluded by the static one.
zIndex: 110,
style: (feature) => {
if (feature.get('band')) return BAND;
const label = feature.get('label');
if (typeof label === 'string') return knob(label, Boolean(feature.get('clamped')));
return feature.get('clamped') ? CLAMPED : GUIDE;
},
});
}
/**
* Shows the handle at the position implied by `value`, plus a guide line back to
* where it started. `value` is already clamped by `projectHandleValue`, so a
* ghost pinned at the bound is the correct picture: the road cannot go further.
*/
show(handle: EditHandle, value: number): void {
const origin = fromLonLat(handle.position);
const moved = fromLonLat(handlePositionFor(handle, value));
const clamped = value >= handle.value.max - 1e-6 || value <= handle.value.min + 1e-6;
const delta = value - handle.value.current;
this.source.clear();
this.source.addFeatures([
new Feature({ geometry: new LineString([origin, moved]), clamped }),
new Feature({
geometry: new Point(moved),
label: `${delta >= 0 ? '+' : ''}${delta.toFixed(2)}`,
clamped,
}),
]);
}
/**
* Shows the interval a range drag is resizing: a band along the road plus its
* length. A pair of dots cannot convey *where* the edit applies, which is the
* whole point of the range handles.
*/
showInterval(range: IntervalRangeHandle, anchor: RoadIntervalAnchor): void {
const span = Math.max(0, anchor.endStation - anchor.startStation);
const steps = 24;
const points: Coordinate[] = [];
for (let index = 0; index <= steps; index += 1) {
const point = coordinateAtStation(range.centerline, anchor.startStation + (span * index) / steps);
if (point) points.push(point);
}
if (points.length < 2) return;
// Pinned when the dragged end has run into the reserve or the other end.
const station = range.end === 'start' ? anchor.startStation : anchor.endStation;
const clamped = station <= range.window.minStation + 1e-6 || station >= range.window.maxStation - 1e-6;
this.source.clear();
this.source.addFeatures([
new Feature({ geometry: new LineString(points.map((point) => fromLonLat(point))), band: true }),
new Feature({
geometry: new Point(fromLonLat(points[range.end === 'start' ? 0 : points.length - 1])),
label: `${(span * range.roadLengthMeters).toFixed(1)}`,
clamped,
}),
]);
}
clear(): void {
this.source.clear();
}
}

View File

@@ -15,9 +15,10 @@ 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 RegularShape from 'ol/style/RegularShape';
import Style from 'ol/style/Style';
import { fromLonLat } from './meters';
import { disabledReasonOf } from './selection';
import { disabledReasonOf, type IntervalRangeHandle } from './selection';
import type { EditHandle } from './types';
/** The only property a handle feature carries. */
@@ -29,6 +30,20 @@ const EDITABLE_STYLE: Record<string, Style> = {
'road-lane-divider': handleStyle('#8f6fd0'),
};
/**
* Range ends are squares so they never read as another value knob: dragging one
* moves where the edit applies along the road, not how much it changes.
*/
const RANGE_STYLE = new Style({
image: new RegularShape({
points: 4,
radius: 6,
angle: Math.PI / 4,
fill: new Fill({ color: '#296956' }),
stroke: new Stroke({ color: '#fff', width: 2 }),
}),
});
/** Reserve handles stay visible so the boundary is explainable, but read as inert. */
const DISABLED_STYLE = new Style({
image: new CircleStyle({
@@ -52,6 +67,7 @@ export class EditHandleLayer {
readonly layer: VectorLayer<VectorSource>;
private readonly source = new VectorSource();
private index = new Map<string, EditHandle>();
private ranges = new Map<string, IntervalRangeHandle>();
constructor() {
this.layer = new VectorLayer({
@@ -59,19 +75,28 @@ export class EditHandleLayer {
// Above every baseline layer, so a handle is never hidden under a surface.
zIndex: 100,
style: (feature) => {
const handle = this.handle(String(feature.get(HANDLE_ID)));
const id = String(feature.get(HANDLE_ID));
if (this.ranges.has(id)) return RANGE_STYLE;
const handle = this.index.get(id);
if (!handle) return undefined;
return handle.editable ? EDITABLE_STYLE[handle.kind] : DISABLED_STYLE;
},
});
}
/** Replaces the rendered handles. Only this source is touched. */
render(handles: EditHandle[]): void {
/**
* Replaces the rendered handles. Only this source is touched.
*
* Range ends live in the same source because design.md allots the map three
* edit sources, not four — they are still handles, just handles that move the
* anchor rather than the value.
*/
render(handles: EditHandle[], ranges: IntervalRangeHandle[] = []): void {
this.index = new Map(handles.map((handle) => [handle.handleId, handle]));
this.ranges = new Map(ranges.map((range) => [range.handleId, range]));
this.source.clear();
this.source.addFeatures(
handles.map(
[...handles, ...ranges].map(
(handle) =>
new Feature({
geometry: new Point(fromLonLat(handle.position)),
@@ -83,9 +108,15 @@ export class EditHandleLayer {
clear(): void {
this.index = new Map();
this.ranges = new Map();
this.source.clear();
}
/** Range lookup, the anchor-moving counterpart of `handle()`. */
range(handleId: string): IntervalRangeHandle | undefined {
return this.ranges.get(handleId);
}
/** Manifest lookup — the single path from a rendered feature back to semantics. */
handle(handleId: string): EditHandle | undefined {
return this.index.get(handleId);

View File

@@ -1,11 +1,14 @@
import { describe, expect, it } from 'vitest';
import {
azimuthBetween,
coordinateAtStation,
fromLonLat,
haversineMeters,
mercatorUnitsForMeters,
offsetCoordinate,
polylineLengthMeters,
signedMetersAlongAxis,
tangentAzimuthAt,
toLonLat,
type Coordinate,
} from './meters';
@@ -92,6 +95,85 @@ describe('signed axis projection', () => {
});
});
describe('station interpolation', () => {
const straight: Coordinate[] = [
[0, 0],
[0, 0.002],
];
const bent: Coordinate[] = [
[0, 0],
[0, 0.001],
[0.001, 0.001],
];
it('returns the ends at station 0 and 1', () => {
expect(coordinateAtStation(straight, 0)).toEqual([0, 0]);
expect(coordinateAtStation(straight, 1)).toEqual([0, 0.002]);
});
it('interpolates by arc length, not by vertex index', () => {
const middle = coordinateAtStation(straight, 0.5)!;
expect(middle[1]).toBeCloseTo(0.001, 7);
// On the bent line the halfway point lands at the corner, because both legs
// are the same length — index-based interpolation would land elsewhere.
const corner = coordinateAtStation(bent, 0.5)!;
expect(corner[0]).toBeCloseTo(0, 5);
expect(corner[1]).toBeCloseTo(0.001, 5);
});
it('clamps stations outside 0..1', () => {
expect(coordinateAtStation(straight, -1)).toEqual([0, 0]);
expect(coordinateAtStation(straight, 2)).toEqual([0, 0.002]);
});
it('handles degenerate input without throwing', () => {
expect(coordinateAtStation([], 0.5)).toBeNull();
expect(coordinateAtStation([[1, 2]], 0.5)).toEqual([1, 2]);
expect(
coordinateAtStation(
[
[1, 2],
[1, 2],
],
0.5,
),
).toEqual([1, 2]);
});
});
describe('bearings', () => {
it('reads the cardinal directions', () => {
expect(azimuthBetween([0, 0], [0, 0.001])).toBeCloseTo(0, 4);
expect(azimuthBetween([0, 0], [0.001, 0])).toBeCloseTo(90, 4);
expect(azimuthBetween([0, 0.001], [0, 0])).toBeCloseTo(180, 4);
expect(azimuthBetween([0.001, 0], [0, 0])).toBeCloseTo(270, 4);
});
it('takes the tangent from the segment the station falls in', () => {
const bent: Coordinate[] = [
[0, 0],
[0, 0.001],
[0.001, 0.001],
];
// First leg runs north, second runs east.
expect(tangentAzimuthAt(bent, 0.25)).toBeCloseTo(0, 3);
expect(tangentAzimuthAt(bent, 0.75)).toBeCloseTo(90, 3);
});
it('falls back to a usable value for degenerate lines', () => {
expect(tangentAzimuthAt([[0, 0]], 0.5)).toBe(0);
expect(
tangentAzimuthAt(
[
[0, 0],
[0, 0],
],
0.5,
),
).toBeCloseTo(0, 6);
});
});
describe('spherical helpers', () => {
it('matches a known great-circle distance', () => {
expect(haversineMeters([0, 0], [0, 1])).toBeCloseTo(111195, 0);

View File

@@ -110,3 +110,55 @@ export function offsetCoordinate(point: Coordinate, azimuth: number, meters: num
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
/** Initial bearing from `a` to `b`, degrees clockwise from true north. */
export function azimuthBetween(a: Coordinate, b: Coordinate): number {
const lat1 = toRadians(a[1]);
const lat2 = toRadians(b[1]);
const dLon = toRadians(b[0] - a[0]);
const y = Math.sin(dLon) * Math.cos(lat2);
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon);
return (toDegrees(Math.atan2(y, x)) + 360) % 360;
}
/**
* Point at a normalized arc-length station along an EPSG:4326 polyline.
*
* Mirrors `coordinateAt()` in `src/compile/direct-edit-solver.js`. The client
* needs its own copy because the ghost has to draw the affected interval band
* along the road, which is a browser-only concern — the server's copy stays
* authoritative for the handle positions it publishes in the manifest.
*/
export function coordinateAtStation(line: Coordinate[], station: number): Coordinate | null {
if (line.length === 0) return null;
if (line.length === 1) return [line[0][0], line[0][1]];
const total = polylineLengthMeters(line);
const last = line[line.length - 1];
if (!(total > 0)) return [line[0][0], line[0][1]];
let remaining = clamp(station, 0, 1) * total;
for (let index = 1; index < line.length; index += 1) {
const from = line[index - 1];
const to = line[index];
const length = haversineMeters(from, to);
if (length >= remaining) {
const ratio = length > 0 ? remaining / length : 0;
return [from[0] + (to[0] - from[0]) * ratio, from[1] + (to[1] - from[1]) * ratio];
}
remaining -= length;
}
return [last[0], last[1]];
}
/** Direction the road runs at a station, for the range handles' drag axis. */
export function tangentAzimuthAt(line: Coordinate[], station: number): number {
if (line.length < 2) return 0;
const total = polylineLengthMeters(line);
if (!(total > 0)) return azimuthBetween(line[0], line[line.length - 1]);
let remaining = clamp(station, 0, 1) * total;
for (let index = 1; index < line.length; index += 1) {
const length = haversineMeters(line[index - 1], line[index]);
if (length >= remaining) return azimuthBetween(line[index - 1], line[index]);
remaining -= length;
}
return azimuthBetween(line[line.length - 2], line[line.length - 1]);
}

View File

@@ -0,0 +1,97 @@
// The `editPreview` source set: authoritative geometry returned by the solver.
//
// Baseline layers stay read-only, which is the whole point of the split. So the
// preview gets its own layer set built by the same `createLayers()` factory —
// identical styling, no second copy of it — and while a preview is showing, the
// baseline layers it supersedes are hidden rather than overwritten. Clearing the
// preview restores them untouched.
import GeoJSON from 'ol/format/GeoJSON';
import { createLayers, type LayerName } from '../map/layers';
import type { GeoJson, Road } from '../types/state';
import type { PreviewLayerName } from './types';
const geojson = new GeoJSON();
/**
* Which baseline layer each preview collection stands in for. Mirrors the
* grouping `updateLayers()` uses, including the collections that share a layer.
*/
const TARGET: Record<PreviewLayerName, LayerName> = {
roadSurface: 'native',
intersectionSurface: 'native',
sidewalkSurface: 'sidewalks',
laneCenterlines: 'lanes',
edgeLines: 'edgeLines',
laneSeparators: 'markings',
turnArrows: 'markings',
centerLines: 'centerLines',
directionArrows: 'directionArrows',
crosswalks: 'controls',
vehicleStopLines: 'controls',
connectors: 'connectors',
};
function read(value: GeoJson | null | undefined) {
return geojson.readFeatures((value || { type: 'FeatureCollection', features: [] }) as object, {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857',
});
}
export class EditPreviewLayer {
readonly layers: ReturnType<typeof createLayers>;
/** Baseline layers currently hidden because the preview supersedes them. */
private hidden = new Set<LayerName>();
constructor(onRoad: () => Road | null, scene: () => boolean) {
this.layers = createLayers(onRoad, scene);
for (const layer of Object.values(this.layers)) {
layer.setVisible(false);
// Between the baseline and the handles.
layer.setZIndex(50);
}
}
/** Every layer, for handing to the Map constructor once. */
all() {
return Object.values(this.layers);
}
/**
* Replaces only the collections the server actually returned. A collection the
* response omits leaves its baseline layer visible and untouched, which is what
* keeps a partial preview from blanking the rest of the map.
*/
show(preview: Partial<Record<PreviewLayerName, GeoJson | null>>, baseline: ReturnType<typeof createLayers>): void {
const touched = new Set<LayerName>();
for (const name of Object.keys(preview) as PreviewLayerName[]) {
if (preview[name] === undefined) continue;
const target = TARGET[name];
if (!target) continue;
const source = this.layers[target].getSource();
if (!source) continue;
// Two collections can share a layer, so clear on first touch only.
if (!touched.has(target)) {
source.clear();
touched.add(target);
}
source.addFeatures(read(preview[name]));
}
for (const name of touched) {
this.layers[name].setVisible(true);
baseline[name].setVisible(false);
this.hidden.add(name);
}
}
/** Drops the preview and gives the baseline layers their visibility back. */
clear(baseline: ReturnType<typeof createLayers>, visible: Partial<Record<LayerName, boolean>>): void {
for (const name of this.hidden) {
this.layers[name].getSource()?.clear();
this.layers[name].setVisible(false);
baseline[name].setVisible(visible[name] !== false);
}
this.hidden.clear();
}
}

View File

@@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import { PREVIEW_DEBOUNCE_MS, PreviewRequester, type PreviewDraft, type PreviewTransport } from './preview-request';
import { EditSession, type PreviewOutcome } from './session';
import type { EditDiagnostic, EditPreviewResponse } from './types';
type SettledHandler = (outcome: PreviewOutcome, response: EditPreviewResponse) => void;
type ErrorHandler = (error: unknown) => void;
function preview(previewSeq: number, diagnostics: EditDiagnostic[] = []): EditPreviewResponse {
return {
ok: true,
previewSeq,
degraded: false,
revisionId: 'rev-0001',
documentVersion: 0,
constraintStates: [],
diagnostics,
handles: { schema: 'road-edit-handles/v1', revisionId: 'rev-0001', previewSeq, handles: [], reserves: [] },
layers: {},
};
}
/** A transport whose responses are released by hand, to force out-of-order replies. */
function deferredTransport() {
const pending: Array<{ seq: number; resolve: (value: EditPreviewResponse) => void }> = [];
const signals: AbortSignal[] = [];
const send: PreviewTransport = (request, signal) => {
signals.push(signal);
return new Promise<EditPreviewResponse>((resolve) => pending.push({ seq: request.previewSeq, resolve }));
};
return { send, pending, signals };
}
let session: EditSession;
let onSettled: Mock<SettledHandler>;
let onError: Mock<ErrorHandler>;
beforeEach(() => {
vi.useFakeTimers();
session = new EditSession([]);
onSettled = vi.fn<SettledHandler>();
onError = vi.fn<ErrorHandler>();
});
afterEach(() => {
vi.useRealTimers();
});
describe('debounce', () => {
it('coalesces a burst of drag updates into one request', async () => {
const send = vi.fn<PreviewTransport>(async (request) => preview(request.previewSeq));
const requester = new PreviewRequester({ session, send, onSettled });
requester.schedule({ constraints: [] });
requester.schedule({ constraints: [] });
requester.schedule({ constraints: [] });
expect(send).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(PREVIEW_DEBOUNCE_MS);
expect(send).toHaveBeenCalledTimes(1);
expect(onSettled).toHaveBeenCalledTimes(1);
});
it('does not fire before the window elapses', async () => {
const send = vi.fn<PreviewTransport>(async (request) => preview(request.previewSeq));
new PreviewRequester({ session, send, onSettled }).schedule({ constraints: [] });
await vi.advanceTimersByTimeAsync(PREVIEW_DEBOUNCE_MS - 1);
expect(send).not.toHaveBeenCalled();
});
});
describe('pointerup flush', () => {
it('sends immediately and drops the pending debounce', async () => {
const send = vi.fn<PreviewTransport>(async (request) => preview(request.previewSeq));
const requester = new PreviewRequester({ session, send, onSettled });
requester.schedule({ constraints: [] });
requester.flush({ constraints: [] });
// The value the pointer settled on must not wait on, or be swallowed by, a timer.
expect(send).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(500);
expect(send).toHaveBeenCalledTimes(1);
});
});
describe('cancellation', () => {
it('aborts the older request when a newer one starts', () => {
const { send, signals } = deferredTransport();
const requester = new PreviewRequester({ session, send, onSettled });
requester.flush({ constraints: [] });
requester.flush({ constraints: [] });
expect(signals[0].aborted).toBe(true);
expect(signals[1].aborted).toBe(false);
});
it('cancel() drops the timer and aborts in flight', async () => {
const { send, signals } = deferredTransport();
const requester = new PreviewRequester({ session, send, onSettled });
requester.flush({ constraints: [] });
requester.schedule({ constraints: [] });
requester.cancel();
expect(signals[0].aborted).toBe(true);
await vi.advanceTimersByTimeAsync(500);
expect(signals).toHaveLength(1);
});
it('never reports an abort as an error', async () => {
const send = vi.fn<PreviewTransport>(async () => {
throw Object.assign(new Error('cancelled'), { name: 'AbortError' });
});
new PreviewRequester({ session, send, onSettled, onError }).flush({ constraints: [] });
await vi.runAllTimersAsync();
expect(onError).not.toHaveBeenCalled();
expect(onSettled).not.toHaveBeenCalled();
});
it('does report a real failure', async () => {
const send = vi.fn<PreviewTransport>(async () => {
throw new Error('500');
});
new PreviewRequester({ session, send, onSettled, onError }).flush({ constraints: [] });
await vi.runAllTimersAsync();
expect(onError).toHaveBeenCalledTimes(1);
});
});
describe('out-of-order replies', () => {
it('drops a late reply for an earlier drag', async () => {
const { send, pending } = deferredTransport();
const requester = new PreviewRequester({ session, send, onSettled });
requester.flush({ constraints: [] }); // previewSeq 1
requester.flush({ constraints: [] }); // previewSeq 2
expect(pending.map((item) => item.seq)).toEqual([1, 2]);
// The newer answer lands first, then the older one arrives late.
pending[1].resolve(preview(2));
await vi.advanceTimersByTimeAsync(0);
pending[0].resolve(preview(1));
await vi.advanceTimersByTimeAsync(0);
expect(onSettled).toHaveBeenCalledTimes(1);
expect(session.lastValidPreview()?.previewSeq).toBe(2);
});
it('stamps a monotonic previewSeq per request', () => {
const { send, pending } = deferredTransport();
const requester = new PreviewRequester({ session, send, onSettled });
requester.flush({ constraints: [] });
requester.flush({ constraints: [] });
requester.flush({ constraints: [] });
expect(pending.map((item) => item.seq)).toEqual([1, 2, 3]);
});
});
describe('request payload', () => {
it('forwards operations alongside constraints', async () => {
// The 400 this guards against: constraints sent without the operations their
// provenance references. The requester must pass the whole draft through.
const sent: unknown[] = [];
const send = vi.fn<PreviewTransport>(async (request) => {
sent.push(request);
return preview(request.previewSeq);
});
const draft: PreviewDraft = {
constraints: [
{
id: 'c1',
kind: 'road-edge-offset',
anchor: { type: 'road-interval', roadId: 'road:1', startStation: 0.2, endStation: 0.8, side: 'left' },
anchorSnapshot: { coordinate: [0, 0], tangentAzimuth: 0, roadLengthMeters: 100, osmNodeIds: ['1'] },
value: { offsetMeters: 1 },
enabled: true,
status: 'exact',
provenance: { operationId: 'op1', createdAt: 'now' },
},
],
operations: [{ id: 'op1', createdAt: 'now', constraintIds: ['c1'] }],
};
new PreviewRequester({ session, send, onSettled }).flush(draft);
await vi.runAllTimersAsync();
expect(sent).toHaveLength(1);
const request = sent[0] as { previewSeq: number; constraints?: unknown[]; operations?: Array<{ id: string }> };
expect(request.previewSeq).toBe(1);
expect(request.constraints).toHaveLength(1);
expect(request.operations?.map((item) => item.id)).toEqual(['op1']);
});
});
describe('blocked drafts', () => {
it('reports the outcome so the caller can show diagnostics', async () => {
const error: EditDiagnostic = { id: 'd', message: '车道过窄', rule: 'min-lane-width', severity: 'error' };
const send = vi.fn<PreviewTransport>(async (request) => preview(request.previewSeq, [error]));
new PreviewRequester({ session, send, onSettled }).flush({ constraints: [] });
await vi.runAllTimersAsync();
expect(onSettled).toHaveBeenCalledWith({ applied: true, blocked: true }, expect.anything());
// The last valid preview must stay on screen.
expect(session.lastValidPreview()).toBeNull();
});
});

View File

@@ -0,0 +1,96 @@
// Preview requests: debounce, cancel, and hand the response to EditSession.
//
// This layer only sends and cancels. Which responses count is `EditSession`'s
// call — `acceptPreview()` owns the previewSeq watermark — because that decision
// is the one worth unit-testing, and it must not depend on network timing.
//
// The transport is injected so the whole thing runs in node with fake timers.
import type { EditSession, PreviewOutcome } from './session';
import type { EditPreviewRequest, EditPreviewResponse } from './types';
/** Trailing debounce while a drag is live, per design.md's latency budget. */
export const PREVIEW_DEBOUNCE_MS = 80;
export type PreviewTransport = (request: EditPreviewRequest, signal: AbortSignal) => Promise<EditPreviewResponse>;
/**
* One gesture's document fragment. Operations travel with constraints because
* `validateEditDocument()` rejects a constraint whose `provenance.operationId` is
* not a recorded operation — constraints alone come back as a 400.
*/
export type PreviewDraft = Pick<EditPreviewRequest, 'constraints' | 'operations'>;
export interface PreviewRequesterOptions {
session: EditSession;
send: PreviewTransport;
/** Called for every response the session accepted, stale ones excluded. */
onSettled: (outcome: PreviewOutcome, response: EditPreviewResponse) => void;
/** Real failures only; an abort is expected and never reported. */
onError?: (error: unknown) => void;
debounceMs?: number;
}
function isAbort(error: unknown): boolean {
return error instanceof DOMException ? error.name === 'AbortError' : (error as Error)?.name === 'AbortError';
}
export class PreviewRequester {
private timer: ReturnType<typeof setTimeout> | null = null;
private inFlight: AbortController | null = null;
private readonly debounceMs: number;
constructor(private readonly options: PreviewRequesterOptions) {
this.debounceMs = options.debounceMs ?? PREVIEW_DEBOUNCE_MS;
}
/** During a drag: coalesce to one request per debounce window. */
schedule(draft: PreviewDraft): void {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.timer = null;
void this.dispatch(draft);
}, this.debounceMs);
}
/**
* On pointerup: send now. The value the pointer settled on is what the user
* meant, so it must not be dropped by a pending timer or lost to the debounce.
*/
flush(draft: PreviewDraft): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
void this.dispatch(draft);
}
/** Drops the pending timer and aborts anything in flight. */
cancel(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.inFlight?.abort();
this.inFlight = null;
}
private async dispatch(draft: PreviewDraft): Promise<void> {
// Abort the older request before starting a newer one: its answer is already
// obsolete, and leaving it running wastes a solve the user cannot see.
this.inFlight?.abort();
const controller = new AbortController();
this.inFlight = controller;
const previewSeq = this.options.session.nextPreviewSeq();
try {
const response = await this.options.send({ previewSeq, ...draft }, controller.signal);
const outcome = this.options.session.acceptPreview(response);
// A stale response is dropped silently; that is the arbitration working.
if (outcome.applied) this.options.onSettled(outcome, response);
} catch (error) {
if (!isAbort(error)) this.options.onError?.(error);
} finally {
if (this.inFlight === controller) this.inFlight = null;
}
}
}

View File

@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest';
import { fromLonLat, offsetCoordinate, type Coordinate } from './meters';
import { fromLonLat, haversineMeters, offsetCoordinate, type Coordinate } from './meters';
import {
anchorSnapshotFor,
constraintValueFor,
draftConstraint,
handlePositionFor,
insideReserve,
operationFor,
MIN_INTERVAL_STATION,
projectHandleValue,
projectIntervalEnd,
@@ -16,6 +18,14 @@ const CENTER: Coordinate = [116.397, 39.908];
/** The road heads due north, so the manifest reports normal = tangent + 90 = east. */
const AXIS = 90;
const TANGENT = 0;
/**
* Outward direction per side. The manifest's axis is `tangent + 90`, which is the
* geometry compiler's *right* (`offsetLine()` offsets counter-clockwise from the
* direction of travel). Naming them geographically keeps these tests from being
* re-derived from axis signs every time.
*/
const OUTWARD_RIGHT = AXIS;
const OUTWARD_LEFT = AXIS + 180;
function interval(side?: Side, boundaryIndex?: number): RoadIntervalAnchor {
return {
@@ -47,33 +57,36 @@ function dragBy(azimuth: number, meters: number): [Coordinate, Coordinate] {
}
describe('road-edge-offset projection', () => {
it('widens when the left handle is dragged along the axis', () => {
const handle = makeHandle('road-edge-offset', interval('left'));
expect(projectHandleValue(handle, ...dragBy(AXIS, 2))).toBeCloseTo(2, 3);
it('widens whichever side is dragged away from the centreline', () => {
// The bug this pins: the left handle used to be drawn over the right kerb, so
// dragging the visually-left handle moved the right edge. Both sides must read
// as a positive offset when pulled outward.
expect(
projectHandleValue(makeHandle('road-edge-offset', interval('left')), ...dragBy(OUTWARD_LEFT, 2)),
).toBeCloseTo(2, 3);
expect(
projectHandleValue(makeHandle('road-edge-offset', interval('right')), ...dragBy(OUTWARD_RIGHT, 2)),
).toBeCloseTo(2, 3);
});
it('widens when the right handle is dragged the opposite way', () => {
// The right handle sits at tangent - 90 while the manifest still reports
// tangent + 90 as its axis, so "outward" is the negative axis direction.
// Both sides must read as a positive offset, or one of them drags inverted.
const handle = makeHandle('road-edge-offset', interval('right'));
expect(projectHandleValue(handle, ...dragBy(AXIS + 180, 2))).toBeCloseTo(2, 3);
});
it('narrows when the right handle is dragged inward', () => {
const handle = makeHandle('road-edge-offset', interval('right'));
expect(projectHandleValue(handle, ...dragBy(AXIS, 2))).toBeCloseTo(-2, 3);
it('narrows whichever side is dragged toward the centreline', () => {
expect(
projectHandleValue(makeHandle('road-edge-offset', interval('left')), ...dragBy(OUTWARD_RIGHT, 2)),
).toBeCloseTo(-2, 3);
expect(
projectHandleValue(makeHandle('road-edge-offset', interval('right')), ...dragBy(OUTWARD_LEFT, 2)),
).toBeCloseTo(-2, 3);
});
it('adds to the value the manifest already reported', () => {
const handle = makeHandle('road-edge-offset', interval('left'), 1.5);
expect(projectHandleValue(handle, ...dragBy(AXIS, 2))).toBeCloseTo(3.5, 3);
expect(projectHandleValue(handle, ...dragBy(OUTWARD_LEFT, 2))).toBeCloseTo(3.5, 3);
});
it('clamps to the manifest range instead of extrapolating', () => {
const handle = makeHandle('road-edge-offset', interval('left'), 0, -1, 1);
expect(projectHandleValue(handle, ...dragBy(AXIS, 9))).toBeCloseTo(1, 6);
expect(projectHandleValue(handle, ...dragBy(AXIS + 180, 9))).toBeCloseTo(-1, 6);
expect(projectHandleValue(handle, ...dragBy(OUTWARD_LEFT, 9))).toBeCloseTo(1, 6);
expect(projectHandleValue(handle, ...dragBy(OUTWARD_RIGHT, 9))).toBeCloseTo(-1, 6);
});
it('ignores drag perpendicular to the axis', () => {
@@ -85,13 +98,13 @@ describe('road-edge-offset projection', () => {
describe('road-sidewalk-width projection', () => {
it('grows outward and shrinks inward', () => {
const handle = makeHandle('road-sidewalk-width', interval('left'), 2, 0, 8);
expect(projectHandleValue(handle, ...dragBy(AXIS, 1.5))).toBeCloseTo(3.5, 3);
expect(projectHandleValue(handle, ...dragBy(AXIS + 180, 1.5))).toBeCloseTo(0.5, 3);
expect(projectHandleValue(handle, ...dragBy(OUTWARD_LEFT, 1.5))).toBeCloseTo(3.5, 3);
expect(projectHandleValue(handle, ...dragBy(OUTWARD_RIGHT, 1.5))).toBeCloseTo(0.5, 3);
});
it('never produces a negative width', () => {
const handle = makeHandle('road-sidewalk-width', interval('right'), 2, 0, 8);
const scalar = projectHandleValue(handle, ...dragBy(AXIS, 10));
const scalar = projectHandleValue(handle, ...dragBy(OUTWARD_LEFT, 10));
expect(scalar).toBeCloseTo(0, 6);
expect(constraintValueFor(handle, scalar)).toEqual({ widthMeters: 0, transition: 'smoothstep' });
});
@@ -133,6 +146,52 @@ describe('road-lane-divider projection', () => {
});
});
describe('handle position derived from the value', () => {
it('sits where the drag put it while inside the range', () => {
const handle = makeHandle('road-edge-offset', interval('left'));
const value = projectHandleValue(handle, ...dragBy(OUTWARD_LEFT, 2));
expect(haversineMeters(CENTER, handlePositionFor(handle, value))).toBeCloseTo(2, 3);
});
it('stops at the clamp bound instead of following the cursor', () => {
// The probe's decisive failure: ol-ext translated its proxy by the raw
// pointer delta, so a drag reading -24.1 m left the handle 24 m out while the
// constraint clamped at -5.4 m — the handle pointed at a road shape that
// cannot exist. The position must come from the clamped value.
const handle = makeHandle('road-edge-offset', interval('left'), 0, -1, 1);
const value = projectHandleValue(handle, ...dragBy(OUTWARD_LEFT, 9));
expect(value).toBeCloseTo(1, 6);
expect(haversineMeters(CENTER, handlePositionFor(handle, value))).toBeCloseTo(1, 3);
});
it('moves outward on the side the handle belongs to', () => {
const left = makeHandle('road-edge-offset', interval('left'));
const right = makeHandle('road-edge-offset', interval('right'));
// Same positive value, opposite geographic directions.
const leftPosition = handlePositionFor(left, 2);
const rightPosition = handlePositionFor(right, 2);
// Left is the compiler's `heading - 90`, i.e. west of a north-heading road.
expect(leftPosition[0]).toBeLessThan(CENTER[0]);
expect(rightPosition[0]).toBeGreaterThan(CENTER[0]);
expect(haversineMeters(CENTER, leftPosition)).toBeCloseTo(2, 3);
expect(haversineMeters(CENTER, rightPosition)).toBeCloseTo(2, 3);
});
it('leaves the handle alone when the value has not changed', () => {
const handle = makeHandle('road-edge-offset', interval('left'), 1.5);
expect(haversineMeters(CENTER, handlePositionFor(handle, 1.5))).toBeCloseTo(0, 6);
});
it('round-trips an arbitrary in-range drag', () => {
const handle = makeHandle('road-edge-offset', interval('left'), 1, -5, 5);
for (const meters of [-3, -0.5, 0.75, 3.5]) {
const value = projectHandleValue(handle, ...dragBy(OUTWARD_LEFT, meters));
expect(value).toBeCloseTo(1 + meters, 3);
expect(haversineMeters(CENTER, handlePositionFor(handle, value))).toBeCloseTo(Math.abs(meters), 3);
}
});
});
describe('interval range projection', () => {
const window = { minStation: 0.1, maxStation: 0.9 };
const LENGTH = 1000;
@@ -224,4 +283,29 @@ describe('draft constraint', () => {
const handle = makeHandle('road-edge-offset', interval('left'));
expect(anchorSnapshotFor(handle, centerline, []).tangentAzimuth).toBeCloseTo(0, 6);
});
it('produces the operation the constraint references', () => {
// The first wiring of the preview request sent constraints alone and the
// server answered 400: validateEditDocument() rejects a constraint whose
// provenance.operationId is not a recorded operation. The two must be built
// together, so this pins the link.
const handle = makeHandle('road-edge-offset', interval('left'));
const snapshot = anchorSnapshotFor(handle, centerline, []);
const constraint = draftConstraint(handle, handle.anchor, { offsetMeters: 2 }, snapshot, identity);
const operation = operationFor(constraint);
expect(operation.id).toBe(constraint.provenance.operationId);
expect(operation.createdAt).toBe(constraint.provenance.createdAt);
expect(operation.constraintIds).toEqual([constraint.id]);
expect(operation).not.toHaveProperty('author');
});
it('carries the author through when one is recorded', () => {
const handle = makeHandle('road-edge-offset', interval('left'));
const snapshot = anchorSnapshotFor(handle, centerline, []);
const constraint = draftConstraint(handle, handle.anchor, { offsetMeters: 2 }, snapshot, {
...identity,
author: 'dingkang',
});
expect(operationFor(constraint).author).toBe('dingkang');
});
});

View File

@@ -6,13 +6,21 @@
// own range, and packaged as a `native-road-edits/v2` value. The client never
// writes a coordinate into a road polygon.
import { clamp, haversineMeters, polylineLengthMeters, signedMetersAlongAxis, type Coordinate } from './meters';
import {
clamp,
haversineMeters,
offsetCoordinate,
polylineLengthMeters,
signedMetersAlongAxis,
type Coordinate,
} from './meters';
import type {
AnchorSnapshot,
ConstraintValue,
EditHandle,
JunctionReserve,
RoadConstraint,
RoadEditOperation,
RoadIntervalAnchor,
Transition,
} from './types';
@@ -28,17 +36,21 @@ export interface IntervalWindow {
/**
* Turns "along axisAzimuth" into "outward" for a handle.
*
* `makeRoadHandles()` places the left handle at `tangent + 90` and the right one
* at `tangent - 90`, but reports `axisAzimuth = tangent + 90` for both. Since a
* positive `offsetMeters` / `widthMeters` always widens the road, a right-side
* drag measured along that axis has to be negated. Lane dividers are the
* exception: their offset is a signed lateral position already measured along
* the same axis, so the raw projection is the value.
* The manifest reports `axisAzimuth = tangent + 90` for both sides, which is the
* geometry compiler's *right*: `offsetLine()` treats a positive offset as
* counter-clockwise from the direction of travel, and sidewalks are placed at
* `heading + (side === 'left' ? -90 : 90)`. So the left kerb lies along the
* negative axis, and a left-side drag has to be negated for a positive
* `offsetMeters` / `widthMeters` to mean "wider" on both sides.
*
* Lane dividers are the exception: their offset is a signed lateral position
* already measured along the axis (left negative, right positive), so the raw
* projection is the value.
*/
function outwardSign(handle: EditHandle): number {
if (handle.kind === 'road-lane-divider') return 1;
const side = 'side' in handle.anchor ? handle.anchor.side : undefined;
return side === 'right' ? -1 : 1;
return side === 'left' ? -1 : 1;
}
/**
@@ -50,6 +62,21 @@ export function projectHandleValue(handle: EditHandle, from: Coordinate, to: Coo
return clamp(handle.value.current + delta, handle.value.min, handle.value.max);
}
/**
* Where the handle belongs for a given constraint value — the inverse of
* `projectHandleValue`, in EPSG:4326.
*
* This is what stops a handle running away from the geometry it controls. The
* ol-ext probe translated its proxy by the raw pointer delta, so a drag reading
* -24.1 m left the handle 24 m out while the constraint clamped at -5.4 m: the
* handle pointed at a road shape that could never exist. Deriving the position
* from the clamped value instead pins the handle to the limit.
*/
export function handlePositionFor(handle: EditHandle, value: number): Coordinate {
const outward = outwardSign(handle) < 0 ? handle.axisAzimuth + 180 : handle.axisAzimuth;
return offsetCoordinate(handle.position, outward, value - handle.value.current);
}
/** The manifest hangs `boundaryIndex` on the anchor; the document needs it in `value`. */
function boundaryIndexOf(handle: EditHandle): number {
const index = 'boundaryIndex' in handle.anchor ? handle.anchor.boundaryIndex : undefined;
@@ -141,6 +168,23 @@ export function anchorSnapshotFor(handle: EditHandle, centerline: Coordinate[],
};
}
/**
* The operation a drafted constraint has to travel with.
*
* `validateEditDocument()` rejects any constraint whose `provenance.operationId`
* is not a recorded operation, so sending constraints alone is a 400 — which is
* exactly how the first wiring of the preview request failed. Every caller that
* ships a constraint must ship this alongside it.
*/
export function operationFor(constraint: RoadConstraint): RoadEditOperation {
return {
id: constraint.provenance.operationId,
createdAt: constraint.provenance.createdAt,
constraintIds: [constraint.id],
...(constraint.provenance.author ? { author: constraint.provenance.author } : {}),
};
}
export interface DraftIdentity {
constraintId: string;
operationId: string;

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { disabledReasonOf, handlesForSegment, reservesForSegment } from './selection';
import type { Coordinate } from './meters';
import { disabledReasonOf, handlesForSegment, intervalRangeHandles, reservesForSegment } from './selection';
import type { ConstraintKind, EditHandle, HandleManifest, SemanticAnchor } from './types';
function handle(handleId: string, kind: ConstraintKind, anchor: SemanticAnchor, editable = true): EditHandle {
@@ -97,6 +98,74 @@ describe('reservesForSegment', () => {
});
});
describe('intervalRangeHandles', () => {
// Heads due north, about 1112 m long.
const centerline: Coordinate[] = [
[116.397, 39.9],
[116.397, 39.91],
];
const reserves = manifest.reserves;
const parent = handle('h:edge:a', 'road-edge-offset', roadInterval('road:way/1:forward'));
it('produces one handle per end of the affected interval', () => {
const ends = intervalRangeHandles(parent, centerline, reserves, 'seg:1');
expect(ends.map((item) => item.end)).toEqual(['start', 'end']);
expect(ends.map((item) => item.station)).toEqual([0.15, 0.85]);
});
it('derives ids from the parent so the pair stays traceable', () => {
const ends = intervalRangeHandles(parent, centerline, reserves, 'seg:1');
expect(ends.map((item) => item.handleId)).toEqual(['h:edge:a:range:start', 'h:edge:a:range:end']);
expect(ends.every((item) => item.parentHandleId === 'h:edge:a')).toBe(true);
});
it('carries the reserve-free window, not the whole road', () => {
const [start] = intervalRangeHandles(parent, centerline, reserves, 'seg:1');
// The two reserves on seg:1 leave 0.15..0.85, which is exactly the interval
// the solver anchored — a range drag may shrink it but never grow past this.
expect(start.window).toEqual({ minStation: 0.15, maxStation: 0.85 });
});
it('places the ends apart, along the road', () => {
const [start, end] = intervalRangeHandles(parent, centerline, reserves, 'seg:1');
expect(start.position[1]).toBeLessThan(end.position[1]);
expect(start.position[0]).toBeCloseTo(116.397, 6);
// Drag axis is the road direction, which is due north here.
expect(start.tangentAzimuth).toBeCloseTo(0, 3);
expect(start.roadLengthMeters).toBeCloseTo(end.roadLengthMeters, 6);
expect(start.roadLengthMeters).toBeGreaterThan(1000);
});
it('offers nothing for a handle that cannot be dragged', () => {
const blocked = handle('h:blocked', 'road-edge-offset', roadInterval('road:way/1:forward'), false);
expect(intervalRangeHandles(blocked, centerline, reserves, 'seg:1')).toEqual([]);
});
it('offers nothing for a junction anchor', () => {
const approach = handle('h:approach', 'junction-approach-width', {
type: 'junction-approach',
nodeId: '9',
segmentId: 'seg:1',
});
expect(intervalRangeHandles(approach, centerline, reserves, 'seg:1')).toEqual([]);
});
it('offers nothing for a road with no length', () => {
expect(intervalRangeHandles(parent, [[116.397, 39.9]], reserves, 'seg:1')).toEqual([]);
expect(
intervalRangeHandles(
parent,
[
[116.397, 39.9],
[116.397, 39.9],
],
reserves,
'seg:1',
),
).toEqual([]);
});
});
describe('disabledReasonOf', () => {
it('says nothing for an editable handle', () => {
expect(disabledReasonOf(handle('h', 'road-edge-offset', roadInterval('road:way/1:forward')))).toBeUndefined();

View File

@@ -4,7 +4,15 @@
// be unit-tested in node. `handle-layer.ts` is the OL adapter that renders the
// result.
import { isRoadKind, type EditHandle, type HandleManifest, type JunctionReserve } from './types';
import { coordinateAtStation, polylineLengthMeters, tangentAzimuthAt, type Coordinate } from './meters';
import { reserveWindow, type IntervalWindow } from './projection';
import {
isRoadKind,
type EditHandle,
type HandleManifest,
type JunctionReserve,
type RoadIntervalAnchor,
} from './types';
/**
* Road kinds only, and only for the selected road's segment.
@@ -41,3 +49,77 @@ export function disabledReasonOf(handle: EditHandle): string | undefined {
if (handle.editable) return undefined;
return handle.disabledReason || '该手柄位于路口保留区,请进入 JunctionTools 编辑。';
}
/**
* The two ends of a road handle's affected interval, as draggable range handles.
*
* These are synthesised on the client rather than published in the manifest, and
* that is a deliberate deviation from design.md's "客户端不自行推导手柄位置":
*
* - `EditHandle.kind` is a `RoadConstraintKind`, and design.md declares those six
* complete with no gaps. A range end is not a constraint — it moves the anchor
* of an existing one — so it has no kind to carry.
* - Its position is a pure function of data the server already sent: the anchor's
* stations, the reserve window, and the centerline. Echoing it back would be the
* second cursor the cross-layer guide warns about.
* - The ghost has to interpolate stations to coordinates anyway, to draw the
* affected band along the road, so the capability exists client-side regardless.
*
* No semantics are invented here: the legal window comes from `reserves`, the
* interval comes from the anchor the solver built, and the axis is the road's own
* tangent. Only the screen position is derived.
*/
export interface IntervalRangeHandle {
handleId: string;
/** The road handle whose anchor interval this end belongs to. */
parentHandleId: string;
roadId: string;
end: 'start' | 'end';
station: number;
/** EPSG:4326, interpolated along the centerline. */
position: Coordinate;
/** Drag axis: the direction the road runs at this station. */
tangentAzimuth: number;
window: IntervalWindow;
roadLengthMeters: number;
/** The interval being resized, so a drag can call `projectIntervalEnd()` directly. */
anchor: RoadIntervalAnchor;
/** Kept so the ghost can draw the affected band along the road. */
centerline: Coordinate[];
}
export function intervalRangeHandles(
handle: EditHandle,
centerline: Coordinate[],
reserves: JunctionReserve[],
segmentId: string,
): IntervalRangeHandle[] {
if (!handle.editable || handle.anchor.type !== 'road-interval') return [];
const roadLengthMeters = polylineLengthMeters(centerline);
if (!(roadLengthMeters > 0)) return [];
const window = reserveWindow(reserves, segmentId);
const anchor = handle.anchor;
const ends: Array<{ end: 'start' | 'end'; station: number }> = [
{ end: 'start', station: anchor.startStation },
{ end: 'end', station: anchor.endStation },
];
const handles: IntervalRangeHandle[] = [];
for (const { end, station } of ends) {
const position = coordinateAtStation(centerline, station);
if (!position) continue;
handles.push({
handleId: `${handle.handleId}:range:${end}`,
parentHandleId: handle.handleId,
roadId: anchor.roadId,
end,
station,
position,
tangentAzimuth: tangentAzimuthAt(centerline, station),
window,
roadLengthMeters,
anchor,
centerline,
});
}
return handles;
}