feat: scope the handle manifest and re-aim the ghost

Two consequences of the preview dropping from ~1535 ms to ~142 ms.

The manifest is now scoped to the object being edited. It was 844 KB on a
41-road workspace — 239 handles, 89% of the bytes being `affects` id lists at 46
ids per junction handle — shipped on every preview while the map rendered six.
A segment selection returns 12.2 KB, a 69x reduction, and the cost no longer
multiplies with each handle kind we are about to add. Omitting the selection
keeps the full manifest for the compiler and existing callers, and the tests
assert that scoping is a filter of the full manifest rather than a second
derivation.

The ghost is re-aimed from "estimated geometry" to "what you asked for". Its
guide line existed to mark the origin through a long wait that no longer happens,
so it is gone; what remains is what the preview cannot say — the numeric delta
and whether the drag has hit its clamp. The translucent outline
research/joint-solver.md asked for is deliberately not built: drawing it
accurately means recomputing the road surface in the browser, which the design
forbids, and drawing it crudely would be wrong exactly at transitions, junction
boundaries and clamps. A preview that lies is worse than none.

`degraded` finally has a consumer. EditSession has tracked it since the session
work but nothing read it; a slow solve now dims the ghost in place with a pending
label instead of clearing it and letting the geometry flicker, as design.md
requires.

Selection also fixed a latent hazard: the manifest effect reloads on every
selection change, and it used to call session.load() each time, which would have
discarded unsaved edits the moment the user clicked another road. It now adopts a
document only when the compiled document actually changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 12:26:15 +08:00
parent d893e406d6
commit 5292c00926
13 changed files with 305 additions and 41 deletions

View File

@@ -14,6 +14,7 @@ import type {
PreviewLayerName,
RoadConstraint,
RoadEditOperation,
EditSelection,
RoadIntervalAnchor,
VersionConflict,
} from './edit/types';
@@ -105,16 +106,24 @@ function App() {
.catch((error: Error) => setStatus(error.message));
}, []);
const [manifest, setManifest] = useState<HandleManifest | null>(null);
/** Which compiled document the session already adopted, so a re-fetch cannot discard edits. */
const loadedDocument = useRef<string | null>(null);
useEffect(() => {
// Flag off: no manifest request at all, so the network trace matches main.
if (!directEditEnabled) return;
api
.editState()
// Scoped to the selection: the whole-area manifest is 844 KB on a 41-road
// workspace, 89% of it `affects` id lists, and the map renders about six.
.editState(selected?.segmentId ? { segment: selected.segmentId } : undefined)
.then((value) => {
if (!('handles' in value)) return;
setManifest(value.handles);
// The session owns the constraint set from here on, so a drag drafts on top
// of the active document instead of sending a lone constraint.
// Adopt the document only when it actually changed. This effect also runs on
// every selection change, and reloading there would silently discard the
// session's unsaved edits.
const key = `${value.areaId}:${value.documentVersion}`;
if (loadedDocument.current === key) return;
loadedDocument.current = key;
sessionRef.current?.load(value.document.constraints, value.document.operations, value.documentVersion);
touch();
})
@@ -125,7 +134,7 @@ function App() {
// 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]);
}, [state, selected?.segmentId]);
const editHandles = useMemo(() => {
if (!directEditEnabled || !state) return [];
// Handle anchors carry a directional road id while reserves are keyed by
@@ -157,6 +166,10 @@ function App() {
const [editRevision, setEditRevision] = useState(0);
const touch = () => setEditRevision((value) => value + 1);
const session = sessionRef.current;
// Read at send time so the requester, built once, always scopes to the current
// selection without being rebuilt.
const selectionRef = useRef<EditSelection | undefined>(undefined);
selectionRef.current = selected?.segmentId ? { segment: selected.segmentId } : undefined;
/**
* One gesture, one operation id. Minted on the first draft of a drag and cleared
* on release: reusing an id across gestures would put two operations with the
@@ -176,7 +189,7 @@ function App() {
if (!owner) return null;
return new PreviewRequester({
session: owner,
send: (request, signal) => api.editPreview(request, signal),
send: (request, signal) => api.editPreview({ ...request, selection: selectionRef.current }, signal),
onSettled: (outcome, response) => {
setEditDiagnostics(response.diagnostics);
// The response carries a re-solved manifest. Keeping the stale one made the
@@ -471,6 +484,7 @@ function App() {
handles={editHandles}
ranges={editRanges}
preview={preview}
ghostPending={session?.degraded ?? false}
onHandleDrag={(handle, value) => {
const draft = draftFor(handle, value);
if (draft) requester?.schedule(draft);

View File

@@ -34,6 +34,8 @@ interface Props {
/** Live interval while a range end is dragged. */
onRangeDrag?: (range: IntervalRangeHandle, anchor: RoadIntervalAnchor) => void;
onRangeEnd?: (range: IntervalRangeHandle, anchor: RoadIntervalAnchor) => void;
/** Last solve came back degraded; hold the ghost instead of clearing it. */
ghostPending?: boolean;
}
export function MapCanvas({
state,
@@ -50,6 +52,7 @@ export function MapCanvas({
onHandleBlocked,
onRangeDrag,
onRangeEnd,
ghostPending,
}: Props) {
const target = useRef<HTMLDivElement>(null);
const mapRef = useRef<Map | null>(null);
@@ -61,8 +64,8 @@ export function MapCanvas({
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 dragRef = useRef({ onHandleDrag, onHandleDragEnd, onHandleBlocked, onRangeDrag, onRangeEnd, ghostPending });
dragRef.current = { onHandleDrag, onHandleDragEnd, onHandleBlocked, onRangeDrag, onRangeEnd, ghostPending };
const selectedRef = useRef<Road | null>(selected);
const stateRef = useRef(state);
const sceneRef = useRef(scene);
@@ -121,6 +124,7 @@ export function MapCanvas({
onRangeDrag: (range, anchor) => dragRef.current.onRangeDrag?.(range, anchor),
onRangeEnd: (range, anchor) => dragRef.current.onRangeEnd?.(range, anchor),
onBlocked: (handle, reason) => dragRef.current.onHandleBlocked?.(handle, reason),
holdGhost: () => dragRef.current.ghostPending === true,
}),
);
}
@@ -164,6 +168,11 @@ export function MapCanvas({
// Only the editHandles source is replaced; baseline layers are never touched.
handleLayerRef.current?.render(handles ?? [], ranges ?? []);
}, [handles, ranges]);
useEffect(() => {
// A non-degraded answer means the pending ghost has been superseded by real
// geometry. Clearing an empty ghost is a no-op, so this is safe mid-drag.
if (!ghostPending) ghostRef.current?.clear();
}, [ghostPending]);
useEffect(() => {
const previewLayer = previewRef.current;
const baseline = layersRef.current;

View File

@@ -56,6 +56,12 @@ export interface HandleDragOptions {
onRangeEnd: (range: IntervalRangeHandle, anchor: RoadIntervalAnchor) => void;
/** A reserve handle refused the drag, with the manifest's reason. */
onBlocked?: (handle: EditHandle, reason: string) => void;
/**
* True when the last solve came back `degraded`. design.md requires the ghost to
* stay with a pending state rather than clearing and letting the geometry
* flicker while a slow answer is still on the way.
*/
holdGhost?: () => boolean;
}
export function createHandleDragInteraction(options: HandleDragOptions): PointerInteraction {
@@ -126,7 +132,8 @@ export function createHandleDragInteraction(options: HandleDragOptions): Pointer
// 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();
if (options.holdGhost?.()) options.ghost.markPending();
else options.ghost.clear();
return false;
},
});

View File

@@ -21,32 +21,29 @@ 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 {
/**
* Clamped turns the knob amber so "the road cannot go further" reads differently
* from "the drag is still following". Pending dims it while a solve is over
* budget, so a slow answer looks slow rather than broken.
*/
function knob(label: string, clamped: boolean, pending: boolean): Style {
const colour = clamped ? '#d49318' : '#00a5cf';
return new Style({
image: new CircleStyle({
radius: 8,
fill: new Fill({ color: clamped ? '#d49318' : '#00a5cf' }),
stroke: new Stroke({ color: '#fff', width: 2 }),
fill: new Fill({ color: pending ? `${colour}66` : colour }),
stroke: new Stroke({ color: pending ? '#ffffff88' : '#fff', width: 2 }),
}),
text: new Text({
text: label,
text: pending ? `${label} 求解中…` : label,
offsetY: -18,
font: '600 12px system-ui, sans-serif',
fill: new Fill({ color: '#1b2426' }),
fill: new Fill({ color: pending ? '#59696c' : '#1b2426' }),
stroke: new Stroke({ color: '#ffffffcc', width: 3 }),
}),
});
@@ -55,6 +52,7 @@ function knob(label: string, clamped: boolean): Style {
export class EditGhostLayer {
readonly layer: VectorLayer<VectorSource>;
private readonly source = new VectorSource();
private pending = false;
constructor() {
this.layer = new VectorLayer({
@@ -63,32 +61,49 @@ export class EditGhostLayer {
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;
return knob(String(feature.get('label') ?? ''), Boolean(feature.get('clamped')), this.pending);
},
});
}
/**
* 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.
* Shows the handle at the position implied by `value`. `value` is already
* clamped by `projectHandleValue()`, so a ghost pinned at the bound is the
* correct picture: the road cannot go further.
*
* There is deliberately no translucent outline of the resulting road, which
* `research/joint-solver.md` originally asked for. Drawing it accurately means
* recomputing the road surface in the browser — the second geometry algorithm
* the design forbids — and drawing it crudely would be wrong exactly where the
* solver does something non-trivial: transitions, junction boundaries, clamps.
* A preview that lies is worse than none. Since the solve dropped to ~142 ms the
* authoritative geometry arrives fast enough to show the shape itself, so the
* ghost only says what the preview cannot: how far you asked to move, and
* whether you have hit the limit.
*/
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.pending = false;
this.source.clear();
this.source.addFeatures([
new Feature({ geometry: new LineString([origin, moved]), clamped }),
this.source.addFeature(
new Feature({
geometry: new Point(moved),
geometry: new Point(fromLonLat(handlePositionFor(handle, value))),
label: `${delta >= 0 ? '+' : ''}${delta.toFixed(2)}`,
clamped,
}),
]);
);
}
/**
* Holds the ghost while a solve is over budget. design.md requires the client to
* keep the ghost and a pending state rather than let the geometry flicker, so a
* degraded response dims the knob in place instead of clearing it.
*/
markPending(): void {
if (this.source.getFeatures().length === 0) return;
this.pending = true;
this.layer.changed();
}
/**

View File

@@ -172,6 +172,14 @@ export interface EditStateResponse {
handles: HandleManifest;
}
/**
* Scopes the handle manifest to the object being edited. The whole-area manifest
* is 844 KB on a 41-road workspace, 89% of it `affects` id lists, while the map
* renders about six handles. Wire form matches the `?segment=` / `?junction=`
* query the GET route accepts.
*/
export type EditSelection = { segment: string } | { junction: string };
/**
* `POST /api/edit-preview`. Sending `constraints` alone lets the server merge
* them onto the active document; `document` replaces it wholesale. `previewSeq`
@@ -182,6 +190,7 @@ export interface EditPreviewRequest {
constraints?: RoadConstraint[];
operations?: RoadEditOperation[];
document?: RoadEditDocument;
selection?: EditSelection;
}
/** `POST /api/edits`. The version guard is what makes a second tab fail loudly. */

View File

@@ -2,6 +2,7 @@ import type { WorkbenchState } from '../types/state';
import type {
EditPreviewRequest,
EditPreviewResponse,
EditSelection,
EditStateResponse,
SaveEditsRequest,
SaveEditsResponse,
@@ -73,7 +74,10 @@ export const api = {
// Direct edit. Only reached when the `directEdit` flag is on; with it off the
// workbench never touches these routes, so behaviour matches main exactly.
editState: () => request<EditStateResponse | { active: false }>('/api/edit-state'),
editState: (selection?: EditSelection) => {
const query = selection ? `?${new URLSearchParams(selection as Record<string, string>)}` : '';
return request<EditStateResponse | { active: false }>(`/api/edit-state${query}`);
},
/**
* `signal` comes from the caller's AbortController: the request layer only
* sends and cancels, while `EditSession` decides which responses to keep.

View File

@@ -84,7 +84,7 @@ function handle(request, response, session) {
: sendJson(response, 200, { active: false });
if (request.method === 'GET' && url.pathname === '/api/edit-state')
return Promise.resolve()
.then(() => (area ? editState(area) : { active: false }))
.then(() => (area ? editState(area, selectionFrom(url.searchParams)) : { active: false }))
.then((value) => sendJson(response, 200, value))
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/edit-preview')
@@ -280,7 +280,20 @@ function activeRevisionFields(area) {
return { activeRevisionId: store.active.activeRevisionId, documentVersion: store.active.documentVersion };
}
function editState(area) {
// A selection scopes the handle manifest to the object being edited. Whole-area
// manifests are 844 KB on a 41-road workspace — 89% of it the `affects` id lists —
// while the map renders about six handles. Omitting it keeps the full manifest for
// callers that want it.
function selectionFrom(source) {
if (!source) return null;
const segment = source.segment ?? source.get?.('segment');
if (segment) return { type: 'segment', id: String(segment) };
const junction = source.junction ?? source.get?.('junction');
if (junction) return { type: 'junction', id: String(junction) };
return null;
}
function editState(area, selection = null) {
const workspace = path.dirname(area.input);
const initialized = ensureRevisionStore(workspace, area.nativeRoad);
const directEdits = loadEditDocument(initialized.paths.activeEdits);
@@ -288,6 +301,7 @@ function editState(area) {
const resolution = resolveDirectEditConstraints(model, directEdits, {
revisionId: initialized.active.activeRevisionId,
compilerGeometryVersion: GEOMETRY_VERSION,
selection,
});
return {
active: true,
@@ -323,6 +337,7 @@ function editPreview(area, body = {}) {
revisionId: activeState.activeRevisionId,
previewSeq,
compilerGeometryVersion: GEOMETRY_VERSION,
selection: selectionFrom(body.selection),
});
const compiled = compileGeometry(model, loadOverrides(area.outputs.nativeRoadOverrides), {
edgeLines: area.nativeRoad.edgeLines,