feat: drag crosswalk and stop-line offsets on the map
Handles for the two control-marking kinds, completing what step 1 made solvable. They drag along the approach tangent and reuse the whole existing chain — hit test, projection, clamp, ghost, debounce, arbitration, preview, save, undo — without changes to any of it. That reuse is the point of having modelled edits as kind + anchor + axis + value. Ownership: design.md gives junction reserve interiors to JunctionTools, but that rule is about junction *shape* — width, cutback, corner. Placement of the zebra and the stop line is offered nowhere else, so the main map takes it. A segment selection therefore returns its cross-section handles plus the control markings at its ends, and shape kinds stay out so the two editors never offer the same edit. The test that asserted "road kinds only" now states this rule instead. The default placements move to the solver, which owns the editable bounds and is the module a test keeps free of fs/path; native-road.js imports them rather than the reverse, so the IO-free property survives. Deliberately not done: the handle-target generalisation this task's PRD listed as step 2. These kinds persist exactly like the existing junction constraints, so there is no second write target to abstract over yet. Building the discriminator now would be designing for one hypothetical case; the signal pose work will provide the real second case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,10 @@ const EDITABLE_STYLE: Record<string, Style> = {
|
||||
'road-edge-offset': handleStyle('#00a5cf'),
|
||||
'road-sidewalk-width': handleStyle('#d49318'),
|
||||
'road-lane-divider': handleStyle('#8f6fd0'),
|
||||
// Control markings are junction-anchored but main-map editable; a distinct hue
|
||||
// keeps them from reading as another cross-section knob.
|
||||
'junction-crosswalk-inset': handleStyle('#2f8f5b'),
|
||||
'junction-stop-line-offset': handleStyle('#bf3b2e'),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -104,6 +104,10 @@ export function constraintValueFor(
|
||||
return { cutbackMeters: Math.max(0, scalar) };
|
||||
case 'junction-corner-radius':
|
||||
return { radiusMeters: Math.max(0, scalar) };
|
||||
case 'junction-crosswalk-inset':
|
||||
return { insetMeters: Math.max(0, scalar) };
|
||||
case 'junction-stop-line-offset':
|
||||
return { offsetMeters: Math.max(0, scalar) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { coordinateAtStation, polylineLengthMeters, tangentAzimuthAt, type Coordinate } from './meters';
|
||||
import { reserveWindow, type IntervalWindow } from './projection';
|
||||
import {
|
||||
isControlKind,
|
||||
isRoadKind,
|
||||
type EditHandle,
|
||||
type HandleManifest,
|
||||
@@ -15,15 +16,17 @@ import {
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Road kinds only, and only for the selected road's segment.
|
||||
* What the main map may show for a selected road 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.
|
||||
* Two shapes of handle qualify. Road kinds are anchored to a directional road, so
|
||||
* they resolve through the model. Control markings are anchored to a junction
|
||||
* approach and name their segment directly — they sit inside the reserve, but that
|
||||
* ownership rule is about junction *shape*, and nothing else offers this edit.
|
||||
*
|
||||
* Handle anchors carry a directional `roadId`, while `reserves` are keyed by
|
||||
* segment id, so callers pass a resolver instead of parsing ids apart.
|
||||
* Junction shape kinds stay out: 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.
|
||||
*/
|
||||
export function handlesForSegment(
|
||||
manifest: HandleManifest | null,
|
||||
@@ -32,6 +35,7 @@ export function handlesForSegment(
|
||||
): EditHandle[] {
|
||||
if (!manifest || !segmentId) return [];
|
||||
return manifest.handles.filter((handle) => {
|
||||
if (isControlKind(handle.kind)) return 'segmentId' in handle.anchor && handle.anchor.segmentId === segmentId;
|
||||
if (!isRoadKind(handle.kind)) return false;
|
||||
const roadId = 'roadId' in handle.anchor ? handle.anchor.roadId : undefined;
|
||||
return typeof roadId === 'string' && segmentOf(roadId) === segmentId;
|
||||
|
||||
@@ -14,11 +14,25 @@ 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. */
|
||||
/** Kinds the main map owns. Junction *shape* belongs 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;
|
||||
/**
|
||||
* Control markings are anchored to a junction approach but edited from the main
|
||||
* map. They sit inside the reserve, which design.md gives to JunctionTools, but
|
||||
* that rule is about junction *shape* — width, cutback, corner — and nothing else
|
||||
* offers this edit. Placement of the zebra and the stop line is therefore the main
|
||||
* map's, and JunctionTools keeps the three shape kinds below.
|
||||
*/
|
||||
export const CONTROL_KINDS = ['junction-crosswalk-inset', 'junction-stop-line-offset'] as const;
|
||||
export const JUNCTION_KINDS = [
|
||||
'junction-approach-width',
|
||||
'junction-cutback',
|
||||
'junction-corner-radius',
|
||||
...CONTROL_KINDS,
|
||||
] as const;
|
||||
|
||||
export type RoadConstraintKind = (typeof ROAD_KINDS)[number];
|
||||
export type ControlConstraintKind = (typeof CONTROL_KINDS)[number];
|
||||
export type JunctionConstraintKind = (typeof JUNCTION_KINDS)[number];
|
||||
export type ConstraintKind = RoadConstraintKind | JunctionConstraintKind;
|
||||
|
||||
@@ -85,7 +99,8 @@ export type ConstraintValue =
|
||||
| { widthMeters: number; transition?: Transition }
|
||||
| { boundaryIndex: number; offsetMeters: number; transition?: Transition }
|
||||
| { cutbackMeters: number }
|
||||
| { radiusMeters: number };
|
||||
| { radiusMeters: number }
|
||||
| { insetMeters: number };
|
||||
|
||||
export interface RoadConstraint {
|
||||
id: string;
|
||||
@@ -219,6 +234,11 @@ export function isRoadKind(kind: ConstraintKind): kind is RoadConstraintKind {
|
||||
return (ROAD_KINDS as readonly string[]).includes(kind);
|
||||
}
|
||||
|
||||
/** Control markings are junction-anchored but edited from the main map. */
|
||||
export function isControlKind(kind: ConstraintKind): kind is ControlConstraintKind {
|
||||
return (CONTROL_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