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:
31
workbench/client/src/edit/flag.ts
Normal file
31
workbench/client/src/edit/flag.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// The `directEdit` kill switch.
|
||||
//
|
||||
// Default off, per design.md's rollout shape: with it off the workbench must
|
||||
// behave exactly like main — no edit interaction registered, no manifest
|
||||
// request, and none of the three edit sources created. Callers check this once
|
||||
// and skip the whole subsystem, rather than guarding individual call sites.
|
||||
//
|
||||
// Opt in per browser session with `?directEdit=1`, or persistently with
|
||||
// `localStorage.setItem('directEdit', '1')`. A query parameter of `0` wins over
|
||||
// stored state so a tab can always be forced back to the shipped behaviour.
|
||||
|
||||
const STORAGE_KEY = 'directEdit';
|
||||
|
||||
function readFlag(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const parameter = new URLSearchParams(window.location.search).get(STORAGE_KEY);
|
||||
if (parameter === '1' || parameter === 'true') return true;
|
||||
if (parameter === '0' || parameter === 'false') return false;
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
// Private mode can throw on storage access; treat it as "off".
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved once at module load so a single render pass cannot see the flag flip
|
||||
* halfway through and build a half-wired map.
|
||||
*/
|
||||
export const directEditEnabled = readFlag();
|
||||
105
workbench/client/src/edit/handle-layer.ts
Normal file
105
workbench/client/src/edit/handle-layer.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// The `editHandles` source: one Point per draggable handle on the selected road.
|
||||
//
|
||||
// Handle features carry only `handleId`. Everything else — kind, anchor, axis,
|
||||
// range, why a handle is disabled — is looked up in the manifest, so the client
|
||||
// never keeps a second copy of the constraint model. This class owns both the
|
||||
// source and that lookup index, which is why they cannot drift apart.
|
||||
//
|
||||
// Baseline layers are untouched. This source is additive and is only created
|
||||
// when the `directEdit` flag is on.
|
||||
|
||||
import Feature from 'ol/Feature';
|
||||
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 { fromLonLat } from './meters';
|
||||
import { disabledReasonOf } from './selection';
|
||||
import type { EditHandle } from './types';
|
||||
|
||||
/** The only property a handle feature carries. */
|
||||
export const HANDLE_ID = 'handleId';
|
||||
|
||||
const EDITABLE_STYLE: Record<string, Style> = {
|
||||
'road-edge-offset': handleStyle('#00a5cf'),
|
||||
'road-sidewalk-width': handleStyle('#d49318'),
|
||||
'road-lane-divider': handleStyle('#8f6fd0'),
|
||||
};
|
||||
|
||||
/** Reserve handles stay visible so the boundary is explainable, but read as inert. */
|
||||
const DISABLED_STYLE = new Style({
|
||||
image: new CircleStyle({
|
||||
radius: 5,
|
||||
fill: new Fill({ color: '#96a3a6' }),
|
||||
stroke: new Stroke({ color: '#ffffffaa', width: 1.5 }),
|
||||
}),
|
||||
});
|
||||
|
||||
function handleStyle(color: string): Style {
|
||||
return new Style({
|
||||
image: new CircleStyle({
|
||||
radius: 7,
|
||||
fill: new Fill({ color }),
|
||||
stroke: new Stroke({ color: '#fff', width: 2 }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export class EditHandleLayer {
|
||||
readonly layer: VectorLayer<VectorSource>;
|
||||
private readonly source = new VectorSource();
|
||||
private index = new Map<string, EditHandle>();
|
||||
|
||||
constructor() {
|
||||
this.layer = new VectorLayer({
|
||||
source: this.source,
|
||||
// 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)));
|
||||
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 {
|
||||
this.index = new Map(handles.map((handle) => [handle.handleId, handle]));
|
||||
this.source.clear();
|
||||
this.source.addFeatures(
|
||||
handles.map(
|
||||
(handle) =>
|
||||
new Feature({
|
||||
geometry: new Point(fromLonLat(handle.position)),
|
||||
[HANDLE_ID]: handle.handleId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.index = new Map();
|
||||
this.source.clear();
|
||||
}
|
||||
|
||||
/** Manifest lookup — the single path from a rendered feature back to semantics. */
|
||||
handle(handleId: string): EditHandle | undefined {
|
||||
return this.index.get(handleId);
|
||||
}
|
||||
|
||||
/** Only editable handles may start a drag; the rest explain themselves instead. */
|
||||
draggable(handleId: string): EditHandle | undefined {
|
||||
const handle = this.index.get(handleId);
|
||||
return handle?.editable ? handle : undefined;
|
||||
}
|
||||
|
||||
/** Why a handle refuses to move, for the status line. */
|
||||
disabledReason(handleId: string): string | undefined {
|
||||
const handle = this.index.get(handleId);
|
||||
return handle ? disabledReasonOf(handle) : undefined;
|
||||
}
|
||||
}
|
||||
115
workbench/client/src/edit/meters.test.ts
Normal file
115
workbench/client/src/edit/meters.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
fromLonLat,
|
||||
haversineMeters,
|
||||
mercatorUnitsForMeters,
|
||||
offsetCoordinate,
|
||||
polylineLengthMeters,
|
||||
signedMetersAlongAxis,
|
||||
toLonLat,
|
||||
type Coordinate,
|
||||
} from './meters';
|
||||
|
||||
/** A drag of exactly `meters` along `azimuth` starting from an EPSG:4326 point. */
|
||||
function drag(origin: Coordinate, azimuth: number, meters: number): [Coordinate, Coordinate] {
|
||||
return [fromLonLat(origin), fromLonLat(offsetCoordinate(origin, azimuth, meters))];
|
||||
}
|
||||
|
||||
describe('projection round trip', () => {
|
||||
it('recovers a coordinate through 3857 and back', () => {
|
||||
for (const point of [
|
||||
[0, 0],
|
||||
[116.397, 39.908],
|
||||
[-122.42, 37.77],
|
||||
[18.06, 59.33],
|
||||
] as Coordinate[]) {
|
||||
const [lon, lat] = toLonLat(fromLonLat(point));
|
||||
expect(lon).toBeCloseTo(point[0], 9);
|
||||
expect(lat).toBeCloseTo(point[1], 9);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('meter conversion across latitudes', () => {
|
||||
// EPSG:3857 inflates by 1/cos(latitude). The same projected delta is therefore
|
||||
// a different number of real meters depending on where you are, and the
|
||||
// conversion has to reflect that or the geometry stops tracking the cursor.
|
||||
const MERCATOR_DELTA = 100;
|
||||
const eastward = (latitude: number) => {
|
||||
const from = fromLonLat([0, latitude]);
|
||||
const to: Coordinate = [from[0] + MERCATOR_DELTA, from[1]];
|
||||
return signedMetersAlongAxis(from, to, 90);
|
||||
};
|
||||
|
||||
it('scales with the cosine of the latitude', () => {
|
||||
const equator = eastward(0);
|
||||
const high = eastward(60);
|
||||
expect(equator).toBeCloseTo(99.889, 2);
|
||||
expect(high).toBeCloseTo(49.944, 2);
|
||||
expect(high / equator).toBeCloseTo(Math.cos((60 * Math.PI) / 180), 5);
|
||||
});
|
||||
|
||||
it('does not treat a 3857 delta as meters', () => {
|
||||
// The bug this guards: at 60°N the naive reading is 2x the real distance,
|
||||
// so a dragged edge would jump twice as far as the pointer moved.
|
||||
expect(eastward(60)).not.toBeCloseTo(MERCATOR_DELTA, 0);
|
||||
expect(Math.abs(eastward(60) - MERCATOR_DELTA)).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it('keeps the same on-screen drag producing the same on-screen change', () => {
|
||||
// The invariant that actually matters: meters -> map units is the exact
|
||||
// inverse, so at any latitude the drawn geometry lands under the cursor.
|
||||
for (const latitude of [0, 23.5, 39.9, 60, 71]) {
|
||||
const meters = eastward(latitude);
|
||||
expect(mercatorUnitsForMeters(meters, latitude)).toBeCloseTo(MERCATOR_DELTA, 6);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('signed axis projection', () => {
|
||||
it('is positive along the axis and negative against it', () => {
|
||||
const [from, to] = drag([0, 0], 90, 5);
|
||||
expect(signedMetersAlongAxis(from, to, 90)).toBeCloseTo(5, 3);
|
||||
expect(signedMetersAlongAxis(from, to, 270)).toBeCloseTo(-5, 3);
|
||||
});
|
||||
|
||||
it('ignores displacement perpendicular to the axis', () => {
|
||||
const [from, to] = drag([0, 0], 90, 5);
|
||||
expect(signedMetersAlongAxis(from, to, 0)).toBeCloseTo(0, 6);
|
||||
});
|
||||
|
||||
it('projects an oblique drag onto the axis', () => {
|
||||
const [from, to] = drag([0, 0], 45, 10);
|
||||
expect(signedMetersAlongAxis(from, to, 90)).toBeCloseTo(10 * Math.SQRT1_2, 2);
|
||||
expect(signedMetersAlongAxis(from, to, 45)).toBeCloseTo(10, 2);
|
||||
});
|
||||
|
||||
it('measures the same drag identically at high latitude', () => {
|
||||
// Same physical 5 m, far from the equator: the projected numbers differ but
|
||||
// the measured meters must not.
|
||||
const [from, to] = drag([18.06, 59.33], 90, 5);
|
||||
expect(signedMetersAlongAxis(from, to, 90)).toBeCloseTo(5, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spherical helpers', () => {
|
||||
it('matches a known great-circle distance', () => {
|
||||
expect(haversineMeters([0, 0], [0, 1])).toBeCloseTo(111195, 0);
|
||||
});
|
||||
|
||||
it('offsets by exactly the requested distance', () => {
|
||||
for (const azimuth of [0, 45, 90, 180, 315]) {
|
||||
const moved = offsetCoordinate([116.397, 39.908], azimuth, 25);
|
||||
expect(haversineMeters([116.397, 39.908], moved)).toBeCloseTo(25, 6);
|
||||
}
|
||||
});
|
||||
|
||||
it('sums polyline segments', () => {
|
||||
const line: Coordinate[] = [
|
||||
[0, 0],
|
||||
[0, 0.001],
|
||||
[0, 0.002],
|
||||
];
|
||||
expect(polylineLengthMeters(line)).toBeCloseTo(haversineMeters([0, 0], [0, 0.002]), 3);
|
||||
});
|
||||
});
|
||||
112
workbench/client/src/edit/meters.ts
Normal file
112
workbench/client/src/edit/meters.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// Pointer displacement -> meters, and meters -> map units for ghost drawing.
|
||||
//
|
||||
// The map renders in EPSG:3857, which inflates distances by 1/cos(latitude).
|
||||
// Subtracting two 3857 coordinates and calling the result "meters" therefore
|
||||
// desynchronises the pointer from the geometry: at 60°N a 100-unit drag is only
|
||||
// ~50 real meters, so the edge would jump twice as far as the cursor moved.
|
||||
// Every conversion here goes back to EPSG:4326 first and then measures on the
|
||||
// sphere, matching `haversineMeters()` in src/geometry/lane-geometry.js so a
|
||||
// client draft and the server solver agree on what a meter is.
|
||||
//
|
||||
// This module is the only client-side owner of that conversion; nothing else may
|
||||
// reimplement it.
|
||||
|
||||
/** Web Mercator semi-major axis — the radius EPSG:3857 projects with. */
|
||||
const MERCATOR_RADIUS_METERS = 6378137;
|
||||
/** Mean earth radius, identical to src/geometry/lane-geometry.js. */
|
||||
const EARTH_RADIUS_METERS = 6371008.8;
|
||||
|
||||
export type Coordinate = [number, number];
|
||||
|
||||
const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
|
||||
const toDegrees = (radians: number) => (radians * 180) / Math.PI;
|
||||
|
||||
/** EPSG:3857 -> EPSG:4326. Mirrors `toLonLat()` without importing ol. */
|
||||
export function toLonLat([x, y]: Coordinate): Coordinate {
|
||||
return [
|
||||
toDegrees(x / MERCATOR_RADIUS_METERS),
|
||||
toDegrees(2 * Math.atan(Math.exp(y / MERCATOR_RADIUS_METERS)) - Math.PI / 2),
|
||||
];
|
||||
}
|
||||
|
||||
/** EPSG:4326 -> EPSG:3857. Mirrors `fromLonLat()` without importing ol. */
|
||||
export function fromLonLat([lon, lat]: Coordinate): Coordinate {
|
||||
const clamped = Math.max(-89.999999, Math.min(89.999999, lat));
|
||||
return [
|
||||
toRadians(lon) * MERCATOR_RADIUS_METERS,
|
||||
Math.log(Math.tan(Math.PI / 4 + toRadians(clamped) / 2)) * MERCATOR_RADIUS_METERS,
|
||||
];
|
||||
}
|
||||
|
||||
/** Great-circle distance in meters between two EPSG:4326 coordinates. */
|
||||
export function haversineMeters(a: Coordinate, b: Coordinate): number {
|
||||
const lat1 = toRadians(a[1]);
|
||||
const lat2 = toRadians(b[1]);
|
||||
const dLat = toRadians(b[1] - a[1]);
|
||||
const dLon = toRadians(b[0] - a[0]);
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_METERS * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
/** Running length of an EPSG:4326 polyline in meters. */
|
||||
export function polylineLengthMeters(line: Coordinate[]): number {
|
||||
let total = 0;
|
||||
for (let index = 1; index < line.length; index += 1) total += haversineMeters(line[index - 1], line[index]);
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local east/north components of a displacement, in meters. Each component is a
|
||||
* spherical distance measured along a parallel or a meridian, so the result
|
||||
* carries no Mercator inflation.
|
||||
*/
|
||||
export function eastNorthMeters(from: Coordinate, to: Coordinate): { east: number; north: number } {
|
||||
const [fromLon, fromLat] = from;
|
||||
const [toLon, toLat] = to;
|
||||
const midLat = (fromLat + toLat) / 2;
|
||||
const midLon = (fromLon + toLon) / 2;
|
||||
const east = haversineMeters([fromLon, midLat], [toLon, midLat]) * Math.sign(toLon - fromLon);
|
||||
const north = haversineMeters([midLon, fromLat], [midLon, toLat]) * Math.sign(toLat - fromLat);
|
||||
return { east, north };
|
||||
}
|
||||
|
||||
/**
|
||||
* Signed meters of a 3857 displacement projected onto `axisAzimuth` (degrees
|
||||
* clockwise from true north). Positive means "along the axis".
|
||||
*/
|
||||
export function signedMetersAlongAxis(from: Coordinate, to: Coordinate, axisAzimuth: number): number {
|
||||
const { east, north } = eastNorthMeters(toLonLat(from), toLonLat(to));
|
||||
const azimuth = toRadians(axisAzimuth);
|
||||
return east * Math.sin(azimuth) + north * Math.cos(azimuth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of the above: how many 3857 units `meters` covers at `latitude`. Ghost
|
||||
* drawing needs this to turn a constraint value back into an on-screen offset,
|
||||
* and it is what keeps the drawn edge under the cursor at any latitude.
|
||||
*/
|
||||
export function mercatorUnitsForMeters(meters: number, latitude: number): number {
|
||||
const scale = Math.cos(toRadians(latitude)) * (EARTH_RADIUS_METERS / MERCATOR_RADIUS_METERS);
|
||||
return scale === 0 ? 0 : meters / scale;
|
||||
}
|
||||
|
||||
/** Move an EPSG:4326 point `meters` along `azimuth`, on the sphere. */
|
||||
export function offsetCoordinate(point: Coordinate, azimuth: number, meters: number): Coordinate {
|
||||
const angular = meters / EARTH_RADIUS_METERS;
|
||||
const bearing = toRadians(azimuth);
|
||||
const lat = toRadians(point[1]);
|
||||
const lon = toRadians(point[0]);
|
||||
const nextLat = Math.asin(Math.sin(lat) * Math.cos(angular) + Math.cos(lat) * Math.sin(angular) * Math.cos(bearing));
|
||||
const nextLon =
|
||||
lon +
|
||||
Math.atan2(
|
||||
Math.sin(bearing) * Math.sin(angular) * Math.cos(lat),
|
||||
Math.cos(angular) - Math.sin(lat) * Math.sin(nextLat),
|
||||
);
|
||||
return [toDegrees(nextLon), toDegrees(nextLat)];
|
||||
}
|
||||
|
||||
/** Clamp helper shared by the projections. */
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
227
workbench/client/src/edit/projection.test.ts
Normal file
227
workbench/client/src/edit/projection.test.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fromLonLat, offsetCoordinate, type Coordinate } from './meters';
|
||||
import {
|
||||
anchorSnapshotFor,
|
||||
constraintValueFor,
|
||||
draftConstraint,
|
||||
insideReserve,
|
||||
MIN_INTERVAL_STATION,
|
||||
projectHandleValue,
|
||||
projectIntervalEnd,
|
||||
reserveWindow,
|
||||
} from './projection';
|
||||
import type { ConstraintKind, EditHandle, JunctionReserve, RoadIntervalAnchor, Side } from './types';
|
||||
|
||||
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;
|
||||
|
||||
function interval(side?: Side, boundaryIndex?: number): RoadIntervalAnchor {
|
||||
return {
|
||||
type: 'road-interval',
|
||||
roadId: 'road:way/1:forward',
|
||||
startStation: 0.2,
|
||||
endStation: 0.8,
|
||||
...(side ? { side } : {}),
|
||||
...(boundaryIndex ? { boundaryIndex } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeHandle(kind: ConstraintKind, anchor: RoadIntervalAnchor, current = 0, min = -5, max = 5): EditHandle {
|
||||
return {
|
||||
handleId: `handle:${kind}`,
|
||||
kind,
|
||||
anchor,
|
||||
position: CENTER,
|
||||
axisAzimuth: AXIS,
|
||||
value: { current, min, max, unit: 'meter' },
|
||||
affects: [],
|
||||
editable: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** A drag of exactly `meters` along `azimuth`, expressed in EPSG:3857. */
|
||||
function dragBy(azimuth: number, meters: number): [Coordinate, Coordinate] {
|
||||
return [fromLonLat(CENTER), fromLonLat(offsetCoordinate(CENTER, azimuth, meters))];
|
||||
}
|
||||
|
||||
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 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('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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('ignores drag perpendicular to the axis', () => {
|
||||
const handle = makeHandle('road-edge-offset', interval('left'), 1);
|
||||
expect(projectHandleValue(handle, ...dragBy(TANGENT, 4))).toBeCloseTo(1, 6);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('never produces a negative width', () => {
|
||||
const handle = makeHandle('road-sidewalk-width', interval('right'), 2, 0, 8);
|
||||
const scalar = projectHandleValue(handle, ...dragBy(AXIS, 10));
|
||||
expect(scalar).toBeCloseTo(0, 6);
|
||||
expect(constraintValueFor(handle, scalar)).toEqual({ widthMeters: 0, transition: 'smoothstep' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('road-lane-divider projection', () => {
|
||||
it('is a signed lateral offset, with no side flip', () => {
|
||||
const handle = makeHandle('road-lane-divider', interval(undefined, 2), 0, -1.2, 1.2);
|
||||
expect(projectHandleValue(handle, ...dragBy(AXIS, 0.8))).toBeCloseTo(0.8, 3);
|
||||
expect(projectHandleValue(handle, ...dragBy(AXIS + 180, 0.8))).toBeCloseTo(-0.8, 3);
|
||||
});
|
||||
|
||||
it('moves boundaryIndex from the manifest anchor into the document value', () => {
|
||||
const handle = makeHandle('road-lane-divider', interval(undefined, 3), 0, -1, 1);
|
||||
expect(constraintValueFor(handle, 0.4)).toEqual({
|
||||
boundaryIndex: 3,
|
||||
offsetMeters: 0.4,
|
||||
transition: 'smoothstep',
|
||||
});
|
||||
});
|
||||
|
||||
it('adjusts one divider only, not the road width', () => {
|
||||
// The acceptance criterion is that a divider is not an edge-offset side
|
||||
// effect: the same drag on both handles must produce different constraint
|
||||
// shapes, so the solver writes laneDividerOffsets rather than edgeOffsets.
|
||||
const divider = makeHandle('road-lane-divider', interval(undefined, 1), 0, -1, 1);
|
||||
const edge = makeHandle('road-edge-offset', interval('left'), 0, -1, 1);
|
||||
const gesture = dragBy(AXIS, 0.5);
|
||||
const dividerValue = constraintValueFor(divider, projectHandleValue(divider, ...gesture));
|
||||
const edgeValue = constraintValueFor(edge, projectHandleValue(edge, ...gesture));
|
||||
expect(dividerValue).toMatchObject({ boundaryIndex: 1, transition: 'smoothstep' });
|
||||
expect(edgeValue).not.toHaveProperty('boundaryIndex');
|
||||
expect(Object.keys(edgeValue).sort()).toEqual(['offsetMeters', 'transition']);
|
||||
});
|
||||
|
||||
it('refuses a divider handle without a boundaryIndex', () => {
|
||||
const handle = makeHandle('road-lane-divider', interval());
|
||||
expect(() => constraintValueFor(handle, 0.4)).toThrow(/boundaryIndex/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('interval range projection', () => {
|
||||
const window = { minStation: 0.1, maxStation: 0.9 };
|
||||
const LENGTH = 1000;
|
||||
const move = (meters: number) => dragBy(TANGENT, meters);
|
||||
|
||||
it('moves the dragged end by the station equivalent of the drag', () => {
|
||||
const next = projectIntervalEnd(interval(), 'start', ...move(100), TANGENT, LENGTH, window);
|
||||
expect(next.startStation).toBeCloseTo(0.3, 3);
|
||||
expect(next.endStation).toBe(0.8);
|
||||
});
|
||||
|
||||
it('moves the far end independently', () => {
|
||||
const next = projectIntervalEnd(interval(), 'end', ...move(-100), TANGENT, LENGTH, window);
|
||||
expect(next.endStation).toBeCloseTo(0.7, 3);
|
||||
expect(next.startStation).toBe(0.2);
|
||||
});
|
||||
|
||||
it('stops at the junction reserve window', () => {
|
||||
expect(projectIntervalEnd(interval(), 'start', ...move(-400), TANGENT, LENGTH, window).startStation).toBeCloseTo(
|
||||
0.1,
|
||||
6,
|
||||
);
|
||||
expect(projectIntervalEnd(interval(), 'end', ...move(400), TANGENT, LENGTH, window).endStation).toBeCloseTo(0.9, 6);
|
||||
});
|
||||
|
||||
it('never lets the two ends cross', () => {
|
||||
const start = projectIntervalEnd(interval(), 'start', ...move(900), TANGENT, LENGTH, window);
|
||||
expect(start.startStation).toBeCloseTo(0.8 - MIN_INTERVAL_STATION, 6);
|
||||
expect(start.startStation).toBeLessThan(start.endStation);
|
||||
const end = projectIntervalEnd(interval(), 'end', ...move(-900), TANGENT, LENGTH, window);
|
||||
expect(end.endStation).toBeCloseTo(0.2 + MIN_INTERVAL_STATION, 6);
|
||||
expect(end.endStation).toBeGreaterThan(end.startStation);
|
||||
});
|
||||
|
||||
it('leaves the interval alone when the road has no length', () => {
|
||||
expect(projectIntervalEnd(interval(), 'start', ...move(100), TANGENT, 0, window)).toEqual(interval());
|
||||
});
|
||||
});
|
||||
|
||||
describe('junction reserves', () => {
|
||||
const reserves: JunctionReserve[] = [
|
||||
{ nodeId: '1', roadId: 'segment:1', fromStation: 0, toStation: 0.15 },
|
||||
{ nodeId: '2', roadId: 'segment:1', fromStation: 0.82, toStation: 1 },
|
||||
{ nodeId: '3', roadId: 'segment:other', fromStation: 0, toStation: 0.5 },
|
||||
];
|
||||
|
||||
it('derives the editable window from the reserves of that segment only', () => {
|
||||
expect(reserveWindow(reserves, 'segment:1')).toEqual({ minStation: 0.15, maxStation: 0.82 });
|
||||
expect(reserveWindow(reserves, 'segment:none')).toEqual({ minStation: 0, maxStation: 1 });
|
||||
});
|
||||
|
||||
it('reports stations that fall inside a reserve', () => {
|
||||
expect(insideReserve(reserves, 'segment:1', 0.05)).toBe(true);
|
||||
expect(insideReserve(reserves, 'segment:1', 0.5)).toBe(false);
|
||||
expect(insideReserve(reserves, 'segment:1', 0.9)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('draft constraint', () => {
|
||||
const centerline: Coordinate[] = [
|
||||
[116.397, 39.9],
|
||||
[116.397, 39.91],
|
||||
];
|
||||
const identity = { constraintId: 'c-new', operationId: 'op-1', createdAt: '2026-08-27T00:00:00.000Z' };
|
||||
|
||||
it('carries the projected value with exact status and provenance', () => {
|
||||
const handle = makeHandle('road-edge-offset', interval('left'));
|
||||
const snapshot = anchorSnapshotFor(handle, centerline, ['1001', '1002']);
|
||||
const constraint = draftConstraint(handle, handle.anchor, { offsetMeters: 2 }, snapshot, identity);
|
||||
expect(constraint).toMatchObject({
|
||||
id: 'c-new',
|
||||
kind: 'road-edge-offset',
|
||||
enabled: true,
|
||||
status: 'exact',
|
||||
value: { offsetMeters: 2 },
|
||||
provenance: { operationId: 'op-1', createdAt: identity.createdAt },
|
||||
});
|
||||
expect(constraint.anchorSnapshot.roadLengthMeters).toBeGreaterThan(0);
|
||||
expect(constraint.anchorSnapshot.osmNodeIds).toEqual(['1001', '1002']);
|
||||
});
|
||||
|
||||
it('reuses the existing constraint id so a second drag updates in place', () => {
|
||||
const handle = { ...makeHandle('road-edge-offset', interval('left')), constraintId: 'c-existing' };
|
||||
const snapshot = anchorSnapshotFor(handle, centerline, []);
|
||||
expect(draftConstraint(handle, handle.anchor, { offsetMeters: 1 }, snapshot, identity).id).toBe('c-existing');
|
||||
});
|
||||
|
||||
it('records the tangent, not the drag axis, in the snapshot', () => {
|
||||
const handle = makeHandle('road-edge-offset', interval('left'));
|
||||
expect(anchorSnapshotFor(handle, centerline, []).tangentAzimuth).toBeCloseTo(0, 6);
|
||||
});
|
||||
});
|
||||
179
workbench/client/src/edit/projection.ts
Normal file
179
workbench/client/src/edit/projection.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
// Handle drag -> constraint value. The only place a pointer position becomes a
|
||||
// number the server will solve with.
|
||||
//
|
||||
// Nothing here touches OpenLayers or geometry output: a drag is projected onto
|
||||
// the axis the manifest declared, converted to meters, clamped to the manifest's
|
||||
// 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 type {
|
||||
AnchorSnapshot,
|
||||
ConstraintValue,
|
||||
EditHandle,
|
||||
JunctionReserve,
|
||||
RoadConstraint,
|
||||
RoadIntervalAnchor,
|
||||
Transition,
|
||||
} from './types';
|
||||
|
||||
/** Smallest interval the range handles may collapse to, in normalized station. */
|
||||
export const MIN_INTERVAL_STATION = 0.02;
|
||||
|
||||
export interface IntervalWindow {
|
||||
minStation: number;
|
||||
maxStation: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The scalar a drag produces for `handle`, clamped to the range the manifest
|
||||
* declared. `from` / `to` are EPSG:3857 map coordinates.
|
||||
*/
|
||||
export function projectHandleValue(handle: EditHandle, from: Coordinate, to: Coordinate): number {
|
||||
const delta = signedMetersAlongAxis(from, to, handle.axisAzimuth) * outwardSign(handle);
|
||||
return clamp(handle.value.current + delta, handle.value.min, handle.value.max);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
if (!Number.isInteger(index) || (index as number) < 1)
|
||||
throw new Error(`handle ${handle.handleId} is a lane divider without an anchor boundaryIndex`);
|
||||
return index as number;
|
||||
}
|
||||
|
||||
/** Packages a projected scalar as the `value` the server validates. */
|
||||
export function constraintValueFor(
|
||||
handle: EditHandle,
|
||||
scalar: number,
|
||||
transition: Transition = 'smoothstep',
|
||||
): ConstraintValue {
|
||||
switch (handle.kind) {
|
||||
case 'road-edge-offset':
|
||||
return { offsetMeters: scalar, transition };
|
||||
case 'road-sidewalk-width':
|
||||
return { widthMeters: Math.max(0, scalar), transition };
|
||||
case 'road-lane-divider':
|
||||
return { boundaryIndex: boundaryIndexOf(handle), offsetMeters: scalar, transition };
|
||||
case 'junction-approach-width':
|
||||
return { widthMeters: Math.max(0, scalar) };
|
||||
case 'junction-cutback':
|
||||
return { cutbackMeters: Math.max(0, scalar) };
|
||||
case 'junction-corner-radius':
|
||||
return { radiusMeters: Math.max(0, scalar) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The station range a road interval may occupy, i.e. everything outside the
|
||||
* junction reserves. Mirrors the window `makeRoadHandles()` centres its handles
|
||||
* in. `reserves` are keyed by segment id, not by directional road id.
|
||||
*/
|
||||
export function reserveWindow(reserves: JunctionReserve[], segmentId: string): IntervalWindow {
|
||||
let minStation = 0;
|
||||
let maxStation = 1;
|
||||
for (const reserve of reserves) {
|
||||
if (reserve.roadId !== segmentId) continue;
|
||||
if (reserve.fromStation <= 0) minStation = Math.max(minStation, reserve.toStation);
|
||||
else maxStation = Math.min(maxStation, reserve.fromStation);
|
||||
}
|
||||
return { minStation, maxStation };
|
||||
}
|
||||
|
||||
/** True when the station sits inside a junction reserve, where the main map may not edit. */
|
||||
export function insideReserve(reserves: JunctionReserve[], segmentId: string, station: number): boolean {
|
||||
return reserves.some(
|
||||
(reserve) => reserve.roadId === segmentId && station >= reserve.fromStation && station <= reserve.toStation,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves one end of the affected interval. The dragged end stays inside the
|
||||
* reserve-free window and never crosses the other end, so the solver always
|
||||
* receives `startStation < endStation` and both ends keep a transition back to
|
||||
* the baseline.
|
||||
*/
|
||||
export function projectIntervalEnd(
|
||||
anchor: RoadIntervalAnchor,
|
||||
end: 'start' | 'end',
|
||||
from: Coordinate,
|
||||
to: Coordinate,
|
||||
tangentAzimuth: number,
|
||||
roadLengthMeters: number,
|
||||
window: IntervalWindow,
|
||||
): RoadIntervalAnchor {
|
||||
if (!(roadLengthMeters > 0)) return anchor;
|
||||
const deltaStation = signedMetersAlongAxis(from, to, tangentAzimuth) / roadLengthMeters;
|
||||
if (end === 'start') {
|
||||
const limit = Math.max(window.minStation, anchor.endStation - MIN_INTERVAL_STATION);
|
||||
return { ...anchor, startStation: clamp(anchor.startStation + deltaStation, window.minStation, limit) };
|
||||
}
|
||||
const limit = Math.min(window.maxStation, anchor.startStation + MIN_INTERVAL_STATION);
|
||||
return { ...anchor, endStation: clamp(anchor.endStation + deltaStation, limit, window.maxStation) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evidence a later reimport uses to relocate this anchor. `centerline` is the
|
||||
* road's EPSG:4326 centerline from the compiled model.
|
||||
*/
|
||||
export function anchorSnapshotFor(handle: EditHandle, centerline: Coordinate[], osmNodeIds: string[]): AnchorSnapshot {
|
||||
return {
|
||||
coordinate: [handle.position[0], handle.position[1]],
|
||||
tangentAzimuth: (((handle.axisAzimuth - 90) % 360) + 360) % 360,
|
||||
roadLengthMeters: polylineLengthMeters(centerline) || haversineMeters(centerline[0], centerline.at(-1)!) || 1,
|
||||
osmNodeIds: [...osmNodeIds],
|
||||
};
|
||||
}
|
||||
|
||||
export interface DraftIdentity {
|
||||
constraintId: string;
|
||||
operationId: string;
|
||||
createdAt: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the constraint a drag drafts. `anchor` is passed separately so a range
|
||||
* drag can widen the interval while the value stays put, and vice versa.
|
||||
*
|
||||
* A drafted constraint is always `exact`: it was just authored against the model
|
||||
* currently on screen. Only a reimport or a compiler upgrade can demote it.
|
||||
*/
|
||||
export function draftConstraint(
|
||||
handle: EditHandle,
|
||||
anchor: RoadConstraint['anchor'],
|
||||
value: ConstraintValue,
|
||||
snapshot: AnchorSnapshot,
|
||||
identity: DraftIdentity,
|
||||
): RoadConstraint {
|
||||
return {
|
||||
id: handle.constraintId || identity.constraintId,
|
||||
kind: handle.kind,
|
||||
anchor,
|
||||
anchorSnapshot: snapshot,
|
||||
value,
|
||||
enabled: true,
|
||||
status: 'exact',
|
||||
provenance: {
|
||||
operationId: identity.operationId,
|
||||
createdAt: identity.createdAt,
|
||||
...(identity.author ? { author: identity.author } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
115
workbench/client/src/edit/selection.test.ts
Normal file
115
workbench/client/src/edit/selection.test.ts
Normal 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 segment’s 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('路口保留区');
|
||||
});
|
||||
});
|
||||
43
workbench/client/src/edit/selection.ts
Normal file
43
workbench/client/src/edit/selection.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// selection -> which handles the main map may show.
|
||||
//
|
||||
// Pure manifest filtering, deliberately free of any OpenLayers import so it can
|
||||
// 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';
|
||||
|
||||
/**
|
||||
* Road kinds only, and only for the selected road's segment.
|
||||
*
|
||||
* Junction kinds stay out even though the manifest carries them: reserve interiors
|
||||
* belong to JunctionTools, and the main map must not offer a second way to edit
|
||||
* them. Reserve-disabled road handles are *kept* rather than dropped, because the
|
||||
* user has to be able to see why that stretch refuses to move.
|
||||
*
|
||||
* Handle anchors carry a directional `roadId`, while `reserves` are keyed by
|
||||
* segment id, so callers pass a resolver instead of parsing ids apart.
|
||||
*/
|
||||
export function handlesForSegment(
|
||||
manifest: HandleManifest | null,
|
||||
segmentId: string | null,
|
||||
segmentOf: (roadId: string) => string | undefined,
|
||||
): EditHandle[] {
|
||||
if (!manifest || !segmentId) return [];
|
||||
return manifest.handles.filter((handle) => {
|
||||
if (!isRoadKind(handle.kind)) return false;
|
||||
const roadId = 'roadId' in handle.anchor ? handle.anchor.roadId : undefined;
|
||||
return typeof roadId === 'string' && segmentOf(roadId) === segmentId;
|
||||
});
|
||||
}
|
||||
|
||||
/** Reserves that apply to one segment, for the range window and the tooltip copy. */
|
||||
export function reservesForSegment(manifest: HandleManifest | null, segmentId: string | null): JunctionReserve[] {
|
||||
if (!manifest || !segmentId) return [];
|
||||
return manifest.reserves.filter((reserve) => reserve.roadId === segmentId);
|
||||
}
|
||||
|
||||
/** Why a handle refuses to move. Falls back to a generic reason so the UI never says nothing. */
|
||||
export function disabledReasonOf(handle: EditHandle): string | undefined {
|
||||
if (handle.editable) return undefined;
|
||||
return handle.disabledReason || '该手柄位于路口保留区,请进入 JunctionTools 编辑。';
|
||||
}
|
||||
190
workbench/client/src/edit/session.test.ts
Normal file
190
workbench/client/src/edit/session.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EditSession } from './session';
|
||||
import type { EditDiagnostic, EditPreviewResponse, RoadConstraint } from './types';
|
||||
|
||||
function constraint(id: string, offsetMeters: number): RoadConstraint {
|
||||
return {
|
||||
id,
|
||||
kind: 'road-edge-offset',
|
||||
anchor: { type: 'road-interval', roadId: 'road:way/1:forward', startStation: 0.2, endStation: 0.8, side: 'left' },
|
||||
anchorSnapshot: { coordinate: [116.397, 39.908], tangentAzimuth: 0, roadLengthMeters: 500, osmNodeIds: ['1'] },
|
||||
value: { offsetMeters },
|
||||
enabled: true,
|
||||
status: 'exact',
|
||||
provenance: { operationId: 'op', createdAt: '2026-08-27T00:00:00.000Z' },
|
||||
};
|
||||
}
|
||||
|
||||
const identity = (n: number) => ({ operationId: `op-${n}`, createdAt: `2026-08-27T00:00:0${n}.000Z` });
|
||||
|
||||
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: {},
|
||||
};
|
||||
}
|
||||
|
||||
const error: EditDiagnostic = {
|
||||
id: 'd1',
|
||||
message: '车道分隔调整会使相邻车道小于 2.4 米。',
|
||||
rule: 'direct-edit-min-lane-width',
|
||||
severity: 'error',
|
||||
};
|
||||
|
||||
describe('command stack', () => {
|
||||
it('starts clean on the loaded baseline', () => {
|
||||
const session = new EditSession([constraint('c1', 1)], 3);
|
||||
expect(session.constraints()).toHaveLength(1);
|
||||
expect(session.canUndo).toBe(false);
|
||||
expect(session.canRedo).toBe(false);
|
||||
expect(session.dirty).toBe(false);
|
||||
expect(session.documentVersion).toBe(3);
|
||||
});
|
||||
|
||||
it('applies one gesture as one undoable command', () => {
|
||||
const session = new EditSession([]);
|
||||
session.commit([constraint('c1', 2)], ['c1'], identity(1));
|
||||
expect(session.constraints()).toEqual([constraint('c1', 2)]);
|
||||
expect(session.canUndo).toBe(true);
|
||||
expect(session.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('undoes and redoes an unsaved command', () => {
|
||||
const session = new EditSession([]);
|
||||
session.commit([constraint('c1', 2)], ['c1'], identity(1));
|
||||
session.commit([constraint('c1', 5)], ['c1'], identity(2));
|
||||
expect(session.undo()).toBe(true);
|
||||
expect(session.constraints()).toEqual([constraint('c1', 2)]);
|
||||
expect(session.undo()).toBe(true);
|
||||
expect(session.constraints()).toEqual([]);
|
||||
expect(session.undo()).toBe(false);
|
||||
expect(session.redo()).toBe(true);
|
||||
expect(session.redo()).toBe(true);
|
||||
expect(session.constraints()).toEqual([constraint('c1', 5)]);
|
||||
expect(session.redo()).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the redo tail once a new gesture is committed', () => {
|
||||
const session = new EditSession([]);
|
||||
session.commit([constraint('c1', 2)], ['c1'], identity(1));
|
||||
session.commit([constraint('c1', 5)], ['c1'], identity(2));
|
||||
session.undo();
|
||||
session.commit([constraint('c1', 9)], ['c1'], identity(3));
|
||||
expect(session.canRedo).toBe(false);
|
||||
expect(session.constraints()).toEqual([constraint('c1', 9)]);
|
||||
});
|
||||
|
||||
it('reports each unsaved gesture as one operation, oldest first', () => {
|
||||
const session = new EditSession([]);
|
||||
session.commit([constraint('c1', 2)], ['c1'], identity(1));
|
||||
session.commit([constraint('c1', 2), constraint('c2', 1)], ['c2'], identity(2));
|
||||
expect(session.pendingOperations()).toEqual([
|
||||
{ id: 'op-1', createdAt: '2026-08-27T00:00:01.000Z', constraintIds: ['c1'] },
|
||||
{ id: 'op-2', createdAt: '2026-08-27T00:00:02.000Z', constraintIds: ['c2'] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saved history is append-only', () => {
|
||||
it('clears the pending list and the dirty flag on save', () => {
|
||||
const session = new EditSession([]);
|
||||
session.commit([constraint('c1', 2)], ['c1'], identity(1));
|
||||
session.markSaved(4);
|
||||
expect(session.dirty).toBe(false);
|
||||
expect(session.pendingOperations()).toEqual([]);
|
||||
expect(session.documentVersion).toBe(4);
|
||||
});
|
||||
|
||||
it('undoes a saved gesture by appending an inverse, not by rewriting it', () => {
|
||||
const session = new EditSession([]);
|
||||
session.commit([constraint('c1', 2)], ['c1'], identity(1));
|
||||
session.markSaved(4);
|
||||
expect(session.undo(identity(2))).toBe(true);
|
||||
expect(session.constraints()).toEqual([]);
|
||||
// The original operation stays; a second one records that it was reversed.
|
||||
expect(session.pendingOperations()).toEqual([
|
||||
{ id: 'op-2', createdAt: '2026-08-27T00:00:02.000Z', constraintIds: ['c1'], inverseOf: 'op-1' },
|
||||
]);
|
||||
expect(session.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to undo saved history without an identity for the inverse', () => {
|
||||
const session = new EditSession([]);
|
||||
session.commit([constraint('c1', 2)], ['c1'], identity(1));
|
||||
session.markSaved(4);
|
||||
expect(session.undo()).toBe(false);
|
||||
expect(session.constraints()).toEqual([constraint('c1', 2)]);
|
||||
});
|
||||
|
||||
it('adopts the server version after a conflict', () => {
|
||||
const session = new EditSession([], 1);
|
||||
session.setDocumentVersion(7);
|
||||
expect(session.documentVersion).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('previewSeq arbitration', () => {
|
||||
it('hands out a monotonic sequence', () => {
|
||||
const session = new EditSession([]);
|
||||
expect([session.nextPreviewSeq(), session.nextPreviewSeq(), session.nextPreviewSeq()]).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('discards a response that arrives after a newer one', () => {
|
||||
const session = new EditSession([]);
|
||||
expect(session.acceptPreview(preview(5)).applied).toBe(true);
|
||||
expect(session.acceptPreview(preview(4)).applied).toBe(false);
|
||||
expect(session.lastValidPreview()?.previewSeq).toBe(5);
|
||||
});
|
||||
|
||||
it('accepts responses in order and keeps the newest', () => {
|
||||
const session = new EditSession([]);
|
||||
session.acceptPreview(preview(1));
|
||||
session.acceptPreview(preview(2));
|
||||
expect(session.lastValidPreview()?.previewSeq).toBe(2);
|
||||
});
|
||||
|
||||
it('lets a stale response through only if nothing newer was applied', () => {
|
||||
const session = new EditSession([]);
|
||||
expect(session.acceptPreview(preview(0)).applied).toBe(true);
|
||||
expect(session.lastValidPreview()?.previewSeq).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps the last valid preview when the draft is rejected', () => {
|
||||
const session = new EditSession([]);
|
||||
session.acceptPreview(preview(1));
|
||||
const outcome = session.acceptPreview(preview(2, [error]));
|
||||
expect(outcome).toEqual({ applied: true, blocked: true });
|
||||
expect(session.lastValidPreview()?.previewSeq).toBe(1);
|
||||
expect(session.diagnostics).toEqual([error]);
|
||||
});
|
||||
|
||||
it('does not let an older valid response overwrite a rejected newer one', () => {
|
||||
// The watermark advances even on rejection, so a late reply for an earlier
|
||||
// drag cannot resurrect itself on top of the current diagnostics.
|
||||
const session = new EditSession([]);
|
||||
session.acceptPreview(preview(1));
|
||||
session.acceptPreview(preview(3, [error]));
|
||||
expect(session.acceptPreview(preview(2)).applied).toBe(false);
|
||||
expect(session.lastValidPreview()?.previewSeq).toBe(1);
|
||||
});
|
||||
|
||||
it('surfaces a warning without blocking', () => {
|
||||
const session = new EditSession([]);
|
||||
const warning: EditDiagnostic = { id: 'd2', message: '提示', rule: 'advisory', severity: 'warning' };
|
||||
expect(session.acceptPreview(preview(1, [warning])).blocked).toBe(false);
|
||||
expect(session.lastValidPreview()?.previewSeq).toBe(1);
|
||||
});
|
||||
|
||||
it('reports a degraded preview so the ghost can stay pending', () => {
|
||||
const session = new EditSession([]);
|
||||
session.acceptPreview({ ...preview(1), degraded: true });
|
||||
expect(session.degraded).toBe(true);
|
||||
});
|
||||
});
|
||||
182
workbench/client/src/edit/session.ts
Normal file
182
workbench/client/src/edit/session.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// EditSession — the pure logic behind direct manipulation.
|
||||
//
|
||||
// No OpenLayers reference lives here on purpose: the command stack, undo/redo
|
||||
// and previewSeq arbitration are the parts most likely to break subtly, so they
|
||||
// stay unit-testable in node. The map is an adapter that feeds this object
|
||||
// pointer events and renders what it reports.
|
||||
//
|
||||
// Preview arbitration sits here rather than in the request layer because the
|
||||
// discard decision is a rule worth testing; the request layer only sends and
|
||||
// cancels.
|
||||
|
||||
import { isBlocking, type ConstraintStateReport, type EditDiagnostic } from './types';
|
||||
import type { EditPreviewResponse, RoadConstraint, RoadEditOperation } from './types';
|
||||
|
||||
export interface CommandIdentity {
|
||||
operationId: string;
|
||||
createdAt: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
interface Command extends CommandIdentity {
|
||||
constraintIds: string[];
|
||||
/** Full constraint set before and after this command. */
|
||||
before: RoadConstraint[];
|
||||
after: RoadConstraint[];
|
||||
/** Set once the command has been persisted; saved history is append-only. */
|
||||
saved: boolean;
|
||||
inverseOf?: string;
|
||||
}
|
||||
|
||||
export interface PreviewOutcome {
|
||||
/** False when the response was stale and dropped. */
|
||||
applied: boolean;
|
||||
/** True when the draft was rejected, so the last valid preview stays on screen. */
|
||||
blocked: boolean;
|
||||
}
|
||||
|
||||
export class EditSession {
|
||||
private baseline: RoadConstraint[];
|
||||
private stack: Command[] = [];
|
||||
private cursor = 0;
|
||||
private seq = 0;
|
||||
/** Highest previewSeq already acted on. -1 means nothing applied yet. */
|
||||
private appliedSeq = -1;
|
||||
private preview: EditPreviewResponse | null = null;
|
||||
private version: number;
|
||||
|
||||
diagnostics: EditDiagnostic[] = [];
|
||||
constraintStates: ConstraintStateReport[] = [];
|
||||
degraded = false;
|
||||
|
||||
constructor(constraints: RoadConstraint[] = [], documentVersion = 0) {
|
||||
this.baseline = [...constraints];
|
||||
this.version = documentVersion;
|
||||
}
|
||||
|
||||
/** Effective constraint set: the top applied command, or the saved baseline. */
|
||||
constraints(): RoadConstraint[] {
|
||||
return this.cursor > 0 ? this.stack[this.cursor - 1].after : this.baseline;
|
||||
}
|
||||
|
||||
get documentVersion(): number {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
get canUndo(): boolean {
|
||||
return this.cursor > 0;
|
||||
}
|
||||
|
||||
get canRedo(): boolean {
|
||||
return this.cursor < this.stack.length;
|
||||
}
|
||||
|
||||
/** Unsaved commands exist, so there is something to save or discard. */
|
||||
get dirty(): boolean {
|
||||
return this.stack.slice(0, this.cursor).some((command) => !command.saved);
|
||||
}
|
||||
|
||||
/** Last authoritative preview; null until one has been accepted. */
|
||||
lastValidPreview(): EditPreviewResponse | null {
|
||||
return this.preview;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes one gesture as a single undoable command. Committing after an undo
|
||||
* drops the redo tail, matching every other editor.
|
||||
*/
|
||||
commit(next: RoadConstraint[], constraintIds: string[], identity: CommandIdentity): void {
|
||||
const before = this.constraints();
|
||||
this.stack = [
|
||||
...this.stack.slice(0, this.cursor),
|
||||
{ ...identity, constraintIds, before, after: [...next], saved: false },
|
||||
];
|
||||
this.cursor = this.stack.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo. An unsaved command is simply un-applied. A saved one is reversed by
|
||||
* appending a forward inverse command, because persisted history must not be
|
||||
* rewritten — the document keeps both operations, the second undoing the first.
|
||||
*/
|
||||
undo(identity?: CommandIdentity): boolean {
|
||||
const command = this.stack[this.cursor - 1];
|
||||
if (!command) return false;
|
||||
if (!command.saved) {
|
||||
this.cursor -= 1;
|
||||
return true;
|
||||
}
|
||||
if (!identity) return false;
|
||||
this.stack = [
|
||||
...this.stack.slice(0, this.cursor),
|
||||
{
|
||||
...identity,
|
||||
constraintIds: command.constraintIds,
|
||||
before: command.after,
|
||||
after: command.before,
|
||||
saved: false,
|
||||
inverseOf: command.operationId,
|
||||
},
|
||||
];
|
||||
this.cursor = this.stack.length;
|
||||
return true;
|
||||
}
|
||||
|
||||
redo(): boolean {
|
||||
if (!this.canRedo) return false;
|
||||
this.cursor += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Operations that still need persisting, oldest first. */
|
||||
pendingOperations(): RoadEditOperation[] {
|
||||
return this.stack
|
||||
.slice(0, this.cursor)
|
||||
.filter((command) => !command.saved)
|
||||
.map((command) => ({
|
||||
id: command.operationId,
|
||||
createdAt: command.createdAt,
|
||||
constraintIds: [...command.constraintIds],
|
||||
...(command.author ? { author: command.author } : {}),
|
||||
...(command.inverseOf ? { inverseOf: command.inverseOf } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** After a successful save: current state becomes the baseline, history closes. */
|
||||
markSaved(documentVersion: number): void {
|
||||
this.baseline = this.constraints();
|
||||
this.stack = this.stack.slice(0, this.cursor).map((command) => ({ ...command, saved: true }));
|
||||
this.cursor = this.stack.length;
|
||||
this.version = documentVersion;
|
||||
}
|
||||
|
||||
/** Adopt the server's version, e.g. after a 409 conflict reports the current one. */
|
||||
setDocumentVersion(documentVersion: number): void {
|
||||
this.version = documentVersion;
|
||||
}
|
||||
|
||||
/** Monotonic per-session sequence stamped on every preview request. */
|
||||
nextPreviewSeq(): number {
|
||||
this.seq += 1;
|
||||
return this.seq;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arbitrates one preview response.
|
||||
*
|
||||
* A response older than the newest one already acted on is dropped outright,
|
||||
* so an out-of-order reply can never overwrite a newer preview. A rejected
|
||||
* draft still advances the watermark — it just keeps the last valid preview on
|
||||
* screen and surfaces the diagnostics instead.
|
||||
*/
|
||||
acceptPreview(response: EditPreviewResponse): PreviewOutcome {
|
||||
if (response.previewSeq < this.appliedSeq) return { applied: false, blocked: false };
|
||||
this.appliedSeq = response.previewSeq;
|
||||
this.diagnostics = response.diagnostics ?? [];
|
||||
this.constraintStates = response.constraintStates ?? [];
|
||||
this.degraded = Boolean(response.degraded);
|
||||
const blocked = this.diagnostics.some(isBlocking);
|
||||
if (!blocked) this.preview = response;
|
||||
return { applied: true, blocked };
|
||||
}
|
||||
}
|
||||
216
workbench/client/src/edit/types.ts
Normal file
216
workbench/client/src/edit/types.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
// Single owner for the direct-edit wire contract.
|
||||
//
|
||||
// The server defines these shapes in two places: `src/compile/direct-edit-solver.js`
|
||||
// emits the handle manifest and preview payload, `src/compile/native-road-edits.js`
|
||||
// validates the persisted `native-road-edits/v2` document. Every client consumer
|
||||
// imports from here rather than casting response fields locally, so there is one
|
||||
// definition of "a valid manifest" on this side of the wire.
|
||||
|
||||
import type { GeoJson } from '../types/state';
|
||||
|
||||
export type Side = 'left' | 'right';
|
||||
export type Transition = 'smoothstep' | 'linear';
|
||||
|
||||
export const HANDLE_MANIFEST_SCHEMA = 'road-edit-handles/v1';
|
||||
export const EDITS_SCHEMA = 'native-road-edits/v2';
|
||||
|
||||
/** Kinds the main map owns. Junction kinds belong to JunctionTools. */
|
||||
export const ROAD_KINDS = ['road-edge-offset', 'road-sidewalk-width', 'road-lane-divider'] as const;
|
||||
export const JUNCTION_KINDS = ['junction-approach-width', 'junction-cutback', 'junction-corner-radius'] as const;
|
||||
|
||||
export type RoadConstraintKind = (typeof ROAD_KINDS)[number];
|
||||
export type JunctionConstraintKind = (typeof JUNCTION_KINDS)[number];
|
||||
export type ConstraintKind = RoadConstraintKind | JunctionConstraintKind;
|
||||
|
||||
export type ConstraintStatus = 'exact' | 'recheck' | 'pending' | 'conflicted' | 'stale';
|
||||
|
||||
export type SemanticAnchor =
|
||||
| { type: 'road-station'; roadId: string; station: number; side?: Side }
|
||||
| {
|
||||
type: 'road-interval';
|
||||
roadId: string;
|
||||
startStation: number;
|
||||
endStation: number;
|
||||
side?: Side;
|
||||
// The manifest hangs boundaryIndex on the anchor for lane dividers, but the
|
||||
// persisted document carries it in `value.boundaryIndex`. `draftConstraint`
|
||||
// in projection.ts is the only place that moves it across.
|
||||
boundaryIndex?: number;
|
||||
}
|
||||
| { type: 'junction-approach'; nodeId: string; segmentId: string; side?: Side }
|
||||
| { type: 'junction-corner'; nodeId: string; incomingRoadId: string; outgoingRoadId: string };
|
||||
|
||||
export type RoadIntervalAnchor = Extract<SemanticAnchor, { type: 'road-interval' }>;
|
||||
|
||||
export interface EditHandle {
|
||||
handleId: string;
|
||||
kind: ConstraintKind;
|
||||
anchor: SemanticAnchor;
|
||||
/** EPSG:4326 [lon, lat]. */
|
||||
position: [number, number];
|
||||
/** Draggable direction, degrees clockwise from true north. */
|
||||
axisAzimuth: number;
|
||||
value: { current: number; min: number; max: number; unit: 'meter' };
|
||||
constraintId?: string;
|
||||
/** `native_id` of every derived feature this handle changes. */
|
||||
affects: string[];
|
||||
editable: boolean;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
export interface JunctionReserve {
|
||||
nodeId: string;
|
||||
roadId: string;
|
||||
fromStation: number;
|
||||
toStation: number;
|
||||
}
|
||||
|
||||
export interface HandleManifest {
|
||||
schema: typeof HANDLE_MANIFEST_SCHEMA;
|
||||
revisionId: string | null;
|
||||
previewSeq: number;
|
||||
handles: EditHandle[];
|
||||
reserves: JunctionReserve[];
|
||||
}
|
||||
|
||||
export interface AnchorSnapshot {
|
||||
coordinate: [number, number];
|
||||
tangentAzimuth: number;
|
||||
roadLengthMeters: number;
|
||||
osmNodeIds: string[];
|
||||
}
|
||||
|
||||
export type ConstraintValue =
|
||||
| { offsetMeters: number; transition?: Transition }
|
||||
| { widthMeters: number; transition?: Transition }
|
||||
| { boundaryIndex: number; offsetMeters: number; transition?: Transition }
|
||||
| { cutbackMeters: number }
|
||||
| { radiusMeters: number };
|
||||
|
||||
export interface RoadConstraint {
|
||||
id: string;
|
||||
kind: ConstraintKind;
|
||||
anchor: SemanticAnchor;
|
||||
anchorSnapshot: AnchorSnapshot;
|
||||
value: ConstraintValue;
|
||||
enabled: boolean;
|
||||
status: ConstraintStatus;
|
||||
provenance: { operationId: string; createdAt: string; author?: string };
|
||||
}
|
||||
|
||||
export interface RoadEditOperation {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
constraintIds: string[];
|
||||
author?: string;
|
||||
inverseOf?: string;
|
||||
}
|
||||
|
||||
export interface RoadEditDocument {
|
||||
schema: typeof EDITS_SCHEMA;
|
||||
documentVersion: number;
|
||||
base: { osmSha256: string | null; areaConfigSha256: string | null; compilerGeometryVersion: string | null };
|
||||
constraints: RoadConstraint[];
|
||||
operations: RoadEditOperation[];
|
||||
}
|
||||
|
||||
export interface EditDiagnostic {
|
||||
id: string;
|
||||
message: string;
|
||||
rule: string;
|
||||
severity?: string;
|
||||
subjectId?: string;
|
||||
geometry?: { type: string; coordinates: unknown };
|
||||
}
|
||||
|
||||
export interface ConstraintStateReport {
|
||||
constraintId: string;
|
||||
kind: ConstraintKind;
|
||||
status: ConstraintStatus;
|
||||
applied: boolean;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
/** Preview layer names the server replaces. Anything absent stays untouched. */
|
||||
export const PREVIEW_LAYERS = [
|
||||
'roadSurface',
|
||||
'edgeLines',
|
||||
'sidewalkSurface',
|
||||
'intersectionSurface',
|
||||
'laneCenterlines',
|
||||
'laneSeparators',
|
||||
'centerLines',
|
||||
'directionArrows',
|
||||
'turnArrows',
|
||||
'crosswalks',
|
||||
'vehicleStopLines',
|
||||
'connectors',
|
||||
] as const;
|
||||
|
||||
export type PreviewLayerName = (typeof PREVIEW_LAYERS)[number];
|
||||
|
||||
export interface EditPreviewResponse {
|
||||
ok: true;
|
||||
previewSeq: number;
|
||||
degraded: boolean;
|
||||
revisionId: string | null;
|
||||
documentVersion: number;
|
||||
constraintStates: ConstraintStateReport[];
|
||||
diagnostics: EditDiagnostic[];
|
||||
handles: HandleManifest;
|
||||
layers: Partial<Record<PreviewLayerName, GeoJson | null>>;
|
||||
}
|
||||
|
||||
export interface EditStateResponse {
|
||||
active: true;
|
||||
areaId: string;
|
||||
activeRevisionId: string | null;
|
||||
documentVersion: number;
|
||||
document: RoadEditDocument;
|
||||
constraintStates: ConstraintStateReport[];
|
||||
diagnostics: EditDiagnostic[];
|
||||
handles: HandleManifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /api/edit-preview`. Sending `constraints` alone lets the server merge
|
||||
* them onto the active document; `document` replaces it wholesale. `previewSeq`
|
||||
* comes from `EditSession.nextPreviewSeq()` and is echoed back for arbitration.
|
||||
*/
|
||||
export interface EditPreviewRequest {
|
||||
previewSeq: number;
|
||||
constraints?: RoadConstraint[];
|
||||
operations?: RoadEditOperation[];
|
||||
document?: RoadEditDocument;
|
||||
}
|
||||
|
||||
/** `POST /api/edits`. The version guard is what makes a second tab fail loudly. */
|
||||
export interface SaveEditsRequest {
|
||||
expectedDocumentVersion: number;
|
||||
constraints?: RoadConstraint[];
|
||||
operations?: RoadEditOperation[];
|
||||
document?: RoadEditDocument;
|
||||
}
|
||||
|
||||
export interface SaveEditsResponse {
|
||||
ok: true;
|
||||
document: RoadEditDocument;
|
||||
documentVersion: number;
|
||||
activeRevisionId: string | null;
|
||||
}
|
||||
|
||||
/** 409 body from `POST /api/edits`: the version the server actually holds. */
|
||||
export interface VersionConflict {
|
||||
ok: false;
|
||||
error: string;
|
||||
current: { documentVersion: number };
|
||||
}
|
||||
|
||||
export function isRoadKind(kind: ConstraintKind): kind is RoadConstraintKind {
|
||||
return (ROAD_KINDS as readonly string[]).includes(kind);
|
||||
}
|
||||
|
||||
/** True when a diagnostic must block committing the draft. */
|
||||
export function isBlocking(diagnostic: EditDiagnostic): boolean {
|
||||
return diagnostic.severity === 'error';
|
||||
}
|
||||
Reference in New Issue
Block a user