diff --git a/.trellis/tasks/08-28-edit-interaction-polish/check.jsonl b/.trellis/tasks/08-28-edit-interaction-polish/check.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-28-edit-interaction-polish/check.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-28-edit-interaction-polish/implement.jsonl b/.trellis/tasks/08-28-edit-interaction-polish/implement.jsonl new file mode 100644 index 0000000..9dd3234 --- /dev/null +++ b/.trellis/tasks/08-28-edit-interaction-polish/implement.jsonl @@ -0,0 +1 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} diff --git a/.trellis/tasks/08-28-edit-interaction-polish/prd.md b/.trellis/tasks/08-28-edit-interaction-polish/prd.md new file mode 100644 index 0000000..38871b0 --- /dev/null +++ b/.trellis/tasks/08-28-edit-interaction-polish/prd.md @@ -0,0 +1,87 @@ +# 直接编辑交互收尾:ghost 定位与 manifest 裁剪 + +父任务:`.trellis/tasks/08-26-direct-manipulation-road-editor`。 +前置:`08-26-direct-edit-map-editor`(已归档)交付了手柄、拖拽、预览与保存。 + +## 背景 + +`perf: stop recomputing control bounds in overlap tests`(d893e40)把预览从 +~1535ms 降到 ~142ms 之后,两件事的前提变了: + +**1. ghost 的原始职责被大半抵消。** 它当初是为了在 1.5 秒等待里提供即时反馈。 +现在权威几何在约 220ms 内落地(80ms 防抖 + 142ms 求解),用户观察到的是 +「好像是实时修改」——那正是服务端几何,不是 ghost。 + +`research/joint-solver.md:48` 要求 ghost 包含「半透明预估轮廓」,该轮廓未实现。 +**本任务不补它**,因为它与父任务 design.md 的另一条规则冲突: + +> 浏览器不实现第二套道路几何算法 + +准确的预估轮廓就是在浏览器里重算路面。粗糙的轮廓(把边缘线整体外移 N 米) +在过渡段、路口交界、钳位处都是错的——**会撒谎的预览比没有预览更糟**。 + +**2. manifest 裁剪的时机到了。** 现在 41 条路的片区产出 221 个手柄,每次预览全量下发, +客户端再过滤成 6 个。后续每加一类元素(停止线、斑马线、红绿灯、箭头)都会成倍放大, +应在手柄种类增长**之前**把过滤挪到服务端。 + +## 目标 + +把 ghost 重新定位为「意图与约束的读数」,并让 manifest 只返回选中对象的手柄, +使这套子系统在加入新元素类型前处于稳定、可扩展的状态。 + +| 谁 | 回答什么问题 | +| --- | --- | +| ghost | 我**要求**的是什么?到极限了吗?求解还在路上吗? | +| preview | 我**会得到**什么? | + +## 范围 + +### A. ghost 精简 + +去掉回到起点的虚线辅助线。几何已经实时在动,那条线的原始用途(漫长等待中标示起点) +已经消失,现在是视觉噪音。保留 ghost 独有、preview 表达不了的三件事: + +- 圆点手柄本身 +- 数值增量标签(`+2.35 米`)——看几何看不出改了多少 +- 钳位配色——几何单纯停住有歧义:是到极限了还是卡了? + +### B. degraded 状态 + +design.md: + +> 超预算仍返回结果但标 `degraded: true`,客户端保留 ghost 与待定状态,不做几何闪烁。 + +`EditSession.degraded` 已实现并有单测,但**没有任何消费者**。求解超预算时 ghost 必须 +保持可见并显示待定,而不是清除后让几何跳变。 + +### C. manifest 按选中裁剪 + +`GET /api/edit-state` 与 `POST /api/edit-preview` 接受可选的选中标识(segment id), +只返回该对象的手柄。未提供时保持现有全量行为,供其他调用方与既有测试使用。 + +客户端随之停止本地过滤——`handlesForSegment()` 的过滤职责移到服务端, +但该函数**保留**并继续单测:它仍是「哪些手柄属于主地图」这条规则的唯一定义 +(road kinds、排除 junction kinds、保留置灰手柄),服务端与它必须一致。 + +## 不做 + +- 不实现半透明预估轮廓(理由见背景) +- 不做局部编译(d893e40 之后没有必要,且是全项目最高风险项) +- 不做 Web Worker 预览(用户 2026-08-28 明确搁置) +- 不实现区间生效,方块手柄继续隐藏 +- 不新增元素类型 + +## 验收标准 + +- [ ] 拖拽时 ghost 显示圆点 + 数值增量,**不再有**回到起点的虚线 +- [ ] 拖到钳位边界时 ghost 变色,与「仍在跟手」明显可分 +- [ ] `degraded: true` 的响应不清除 ghost,且有可见的待定提示;几何不闪烁 +- [ ] `/api/edit-state`、`/api/edit-preview` 接受选中标识并只返回该 segment 的手柄 +- [ ] 不带选中标识时返回全量手柄,既有测试不受影响 +- [ ] 服务端裁剪结果与客户端 `handlesForSegment()` 对同一输入一致(测试断言) +- [ ] 裁剪前后手柄数与响应体积实测记录在案 +- [ ] `npm run format:check`、`npm run test`、`npm run test:client`、`npm run test:client:unit`、`npm run build` 全绿 + +## 顺序依赖 + +无前置。后继:手柄目标泛化与新元素类型,两者都受益于本任务的裁剪。 diff --git a/.trellis/tasks/08-28-edit-interaction-polish/task.json b/.trellis/tasks/08-28-edit-interaction-polish/task.json new file mode 100644 index 0000000..c7323a6 --- /dev/null +++ b/.trellis/tasks/08-28-edit-interaction-polish/task.json @@ -0,0 +1,26 @@ +{ + "id": "edit-interaction-polish", + "name": "edit-interaction-polish", + "title": "直接编辑交互收尾:ghost 定位与 manifest 裁剪", + "description": "", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "dingkang", + "assignee": "dingkang", + "createdAt": "2026-08-28", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": "08-26-direct-manipulation-road-editor", + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/src/compile/direct-edit-solver.js b/src/compile/direct-edit-solver.js index 5d07f67..d19f712 100644 --- a/src/compile/direct-edit-solver.js +++ b/src/compile/direct-edit-solver.js @@ -684,6 +684,38 @@ function makeJunctionHandles(model, junctionData, constraints) { return handles; } +/** + * Which handles a selection wants. + * + * Whole-manifest responses are expensive: on a 41-road area the handles are + * 844 KB, 89% of it the `affects` id lists (46 ids per junction handle), and the + * map renders about six of them. `selection` therefore scopes the manifest to the + * object being edited. Omitting it keeps the full manifest, which the compiler + * and the existing callers rely on. + * + * The two selection types match the two editors: the main map owns a road + * segment's cross-section, JunctionTools owns a node. + */ +function selectHandles(handles, model, selection) { + if (!selection || !selection.type) return handles; + if (selection.type === 'segment') { + const segmentOf = new Map((model.roads || []).map((road) => [road.id, road.segmentId])); + // Road kinds only: reserve interiors belong to JunctionTools, and the main map + // must not offer a second way to edit them. + return handles.filter( + (handle) => + handle.kind.startsWith('road-') && + handle.anchor && + segmentOf.get(handle.anchor.roadId) === String(selection.id), + ); + } + if (selection.type === 'junction') + return handles.filter( + (handle) => handle.kind.startsWith('junction-') && handle.anchor?.nodeId === String(selection.id), + ); + return handles; +} + function resolveDirectEditConstraints(model, editDocument = null, context = {}) { const diagnostics = []; const constraints = Array.isArray(editDocument && editDocument.constraints) ? editDocument.constraints : []; @@ -693,10 +725,14 @@ function resolveDirectEditConstraints(model, editDocument = null, context = {}) const reconciled = reconcileConstraints(model || {}, constraints, editDocument?.base, junctionData, context); const solved = solveConstraints(model || {}, reconciled, junctionData, diagnostics); handles.reserves = junctionData.reserves; - handles.handles = [ - ...makeRoadHandles(model || {}, groups, handles.reserves, reconciled), - ...makeJunctionHandles(model || {}, junctionData, reconciled), - ]; + handles.handles = selectHandles( + [ + ...makeRoadHandles(model || {}, groups, handles.reserves, reconciled), + ...makeJunctionHandles(model || {}, junctionData, reconciled), + ], + model || {}, + context.selection, + ); return { schema: RESOLUTION_SCHEMA, roadProfiles: solved.roadProfiles, diff --git a/test/direct-edit-solver.js b/test/direct-edit-solver.js index b7ccdb1..cfc3537 100644 --- a/test/direct-edit-solver.js +++ b/test/direct-edit-solver.js @@ -107,6 +107,46 @@ assert.ok( 'the sidewalk handle must follow the same side convention as the edge handle', ); +// A selection scopes the 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 — so every new handle kind multiplies a +// payload the client throws away. +const unscoped = junctionResolution.handles.handles; +const segmentScoped = resolveDirectEditConstraints(junctionModel, document([]), { + selection: { type: 'segment', id: 'segment:a' }, +}).handles.handles; +assert.ok(segmentScoped.length > 0, 'a segment selection must still publish its road handles'); +assert.ok(segmentScoped.length < unscoped.length, 'a segment selection must be smaller than the whole manifest'); +assert.ok( + segmentScoped.every((handle) => handle.kind.startsWith('road-')), + 'junction kinds belong to JunctionTools and must not reach a segment selection', +); +assert.ok( + segmentScoped.every((handle) => handle.anchor.roadId === 'road:a'), + 'a segment selection must only carry that segment’s handles', +); +// Scoping must not invent or drop handles: it is a filter of the full manifest. +assert.deepEqual( + segmentScoped.map((handle) => handle.handleId).sort(), + unscoped + .filter((handle) => handle.kind.startsWith('road-') && handle.anchor.roadId === 'road:a') + .map((handle) => handle.handleId) + .sort(), +); + +const junctionScoped = resolveDirectEditConstraints(junctionModel, document([]), { + selection: { type: 'junction', id: 'junction' }, +}).handles.handles; +assert.ok(junctionScoped.length > 0, 'a junction selection must publish that node’s handles'); +assert.ok( + junctionScoped.every((handle) => handle.kind.startsWith('junction-') && handle.anchor.nodeId === 'junction'), + 'a junction selection must only carry that node’s handles', +); + +// Omitting the selection keeps the full manifest, which the compiler and the +// existing callers rely on. +assert.equal(resolveDirectEditConstraints(junctionModel, document([]), {}).handles.handles.length, unscoped.length); + const anchored = constraint({ id: 'edge-on-road-a', anchor: { type: 'road-interval', roadId: 'road:a', startStation: 0.2, endStation: 0.8, side: 'left' }, diff --git a/workbench/client/src/App.tsx b/workbench/client/src/App.tsx index 64159d3..f535f33 100644 --- a/workbench/client/src/App.tsx +++ b/workbench/client/src/App.tsx @@ -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(null); + /** Which compiled document the session already adopted, so a re-fetch cannot discard edits. */ + const loadedDocument = useRef(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(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); diff --git a/workbench/client/src/components/MapCanvas.tsx b/workbench/client/src/components/MapCanvas.tsx index 54a64ce..1d5a54f 100644 --- a/workbench/client/src/components/MapCanvas.tsx +++ b/workbench/client/src/components/MapCanvas.tsx @@ -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(null); const mapRef = useRef(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(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; diff --git a/workbench/client/src/edit/drag-interaction.ts b/workbench/client/src/edit/drag-interaction.ts index ac2cf85..6577174 100644 --- a/workbench/client/src/edit/drag-interaction.ts +++ b/workbench/client/src/edit/drag-interaction.ts @@ -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; }, }); diff --git a/workbench/client/src/edit/ghost-layer.ts b/workbench/client/src/edit/ghost-layer.ts index 5cf0315..02398ac 100644 --- a/workbench/client/src/edit/ghost-layer.ts +++ b/workbench/client/src/edit/ghost-layer.ts @@ -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; 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(); } /** diff --git a/workbench/client/src/edit/types.ts b/workbench/client/src/edit/types.ts index bf5108d..fe8c4b2 100644 --- a/workbench/client/src/edit/types.ts +++ b/workbench/client/src/edit/types.ts @@ -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. */ diff --git a/workbench/client/src/lib/api.ts b/workbench/client/src/lib/api.ts index 78dc70a..50c5b0d 100644 --- a/workbench/client/src/lib/api.ts +++ b/workbench/client/src/lib/api.ts @@ -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('/api/edit-state'), + editState: (selection?: EditSelection) => { + const query = selection ? `?${new URLSearchParams(selection as Record)}` : ''; + return request(`/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. diff --git a/workbench/server.js b/workbench/server.js index 843eddb..937b1c4 100644 --- a/workbench/server.js +++ b/workbench/server.js @@ -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,