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:
1
.trellis/tasks/08-28-edit-interaction-polish/check.jsonl
Normal file
1
.trellis/tasks/08-28-edit-interaction-polish/check.jsonl
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. 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."}
|
||||||
87
.trellis/tasks/08-28-edit-interaction-polish/prd.md
Normal file
87
.trellis/tasks/08-28-edit-interaction-polish/prd.md
Normal file
@@ -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` 全绿
|
||||||
|
|
||||||
|
## 顺序依赖
|
||||||
|
|
||||||
|
无前置。后继:手柄目标泛化与新元素类型,两者都受益于本任务的裁剪。
|
||||||
26
.trellis/tasks/08-28-edit-interaction-polish/task.json
Normal file
26
.trellis/tasks/08-28-edit-interaction-polish/task.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -684,6 +684,38 @@ function makeJunctionHandles(model, junctionData, constraints) {
|
|||||||
return handles;
|
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 = {}) {
|
function resolveDirectEditConstraints(model, editDocument = null, context = {}) {
|
||||||
const diagnostics = [];
|
const diagnostics = [];
|
||||||
const constraints = Array.isArray(editDocument && editDocument.constraints) ? editDocument.constraints : [];
|
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 reconciled = reconcileConstraints(model || {}, constraints, editDocument?.base, junctionData, context);
|
||||||
const solved = solveConstraints(model || {}, reconciled, junctionData, diagnostics);
|
const solved = solveConstraints(model || {}, reconciled, junctionData, diagnostics);
|
||||||
handles.reserves = junctionData.reserves;
|
handles.reserves = junctionData.reserves;
|
||||||
handles.handles = [
|
handles.handles = selectHandles(
|
||||||
...makeRoadHandles(model || {}, groups, handles.reserves, reconciled),
|
[
|
||||||
...makeJunctionHandles(model || {}, junctionData, reconciled),
|
...makeRoadHandles(model || {}, groups, handles.reserves, reconciled),
|
||||||
];
|
...makeJunctionHandles(model || {}, junctionData, reconciled),
|
||||||
|
],
|
||||||
|
model || {},
|
||||||
|
context.selection,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
schema: RESOLUTION_SCHEMA,
|
schema: RESOLUTION_SCHEMA,
|
||||||
roadProfiles: solved.roadProfiles,
|
roadProfiles: solved.roadProfiles,
|
||||||
|
|||||||
@@ -107,6 +107,46 @@ assert.ok(
|
|||||||
'the sidewalk handle must follow the same side convention as the edge handle',
|
'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({
|
const anchored = constraint({
|
||||||
id: 'edge-on-road-a',
|
id: 'edge-on-road-a',
|
||||||
anchor: { type: 'road-interval', roadId: 'road:a', startStation: 0.2, endStation: 0.8, side: 'left' },
|
anchor: { type: 'road-interval', roadId: 'road:a', startStation: 0.2, endStation: 0.8, side: 'left' },
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
PreviewLayerName,
|
PreviewLayerName,
|
||||||
RoadConstraint,
|
RoadConstraint,
|
||||||
RoadEditOperation,
|
RoadEditOperation,
|
||||||
|
EditSelection,
|
||||||
RoadIntervalAnchor,
|
RoadIntervalAnchor,
|
||||||
VersionConflict,
|
VersionConflict,
|
||||||
} from './edit/types';
|
} from './edit/types';
|
||||||
@@ -105,16 +106,24 @@ function App() {
|
|||||||
.catch((error: Error) => setStatus(error.message));
|
.catch((error: Error) => setStatus(error.message));
|
||||||
}, []);
|
}, []);
|
||||||
const [manifest, setManifest] = useState<HandleManifest | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
// Flag off: no manifest request at all, so the network trace matches main.
|
// Flag off: no manifest request at all, so the network trace matches main.
|
||||||
if (!directEditEnabled) return;
|
if (!directEditEnabled) return;
|
||||||
api
|
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) => {
|
.then((value) => {
|
||||||
if (!('handles' in value)) return;
|
if (!('handles' in value)) return;
|
||||||
setManifest(value.handles);
|
setManifest(value.handles);
|
||||||
// The session owns the constraint set from here on, so a drag drafts on top
|
// Adopt the document only when it actually changed. This effect also runs on
|
||||||
// of the active document instead of sending a lone constraint.
|
// 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);
|
sessionRef.current?.load(value.document.constraints, value.document.operations, value.documentVersion);
|
||||||
touch();
|
touch();
|
||||||
})
|
})
|
||||||
@@ -125,7 +134,7 @@ function App() {
|
|||||||
// before any area exists and comes back inactive, so the handles never appeared
|
// 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
|
// until a manual reload. A recompile also moves the geometry the manifest
|
||||||
// describes, so the manifest has to be refetched with it.
|
// describes, so the manifest has to be refetched with it.
|
||||||
}, [state]);
|
}, [state, selected?.segmentId]);
|
||||||
const editHandles = useMemo(() => {
|
const editHandles = useMemo(() => {
|
||||||
if (!directEditEnabled || !state) return [];
|
if (!directEditEnabled || !state) return [];
|
||||||
// Handle anchors carry a directional road id while reserves are keyed by
|
// Handle anchors carry a directional road id while reserves are keyed by
|
||||||
@@ -157,6 +166,10 @@ function App() {
|
|||||||
const [editRevision, setEditRevision] = useState(0);
|
const [editRevision, setEditRevision] = useState(0);
|
||||||
const touch = () => setEditRevision((value) => value + 1);
|
const touch = () => setEditRevision((value) => value + 1);
|
||||||
const session = sessionRef.current;
|
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
|
* 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
|
* on release: reusing an id across gestures would put two operations with the
|
||||||
@@ -176,7 +189,7 @@ function App() {
|
|||||||
if (!owner) return null;
|
if (!owner) return null;
|
||||||
return new PreviewRequester({
|
return new PreviewRequester({
|
||||||
session: owner,
|
session: owner,
|
||||||
send: (request, signal) => api.editPreview(request, signal),
|
send: (request, signal) => api.editPreview({ ...request, selection: selectionRef.current }, signal),
|
||||||
onSettled: (outcome, response) => {
|
onSettled: (outcome, response) => {
|
||||||
setEditDiagnostics(response.diagnostics);
|
setEditDiagnostics(response.diagnostics);
|
||||||
// The response carries a re-solved manifest. Keeping the stale one made the
|
// The response carries a re-solved manifest. Keeping the stale one made the
|
||||||
@@ -471,6 +484,7 @@ function App() {
|
|||||||
handles={editHandles}
|
handles={editHandles}
|
||||||
ranges={editRanges}
|
ranges={editRanges}
|
||||||
preview={preview}
|
preview={preview}
|
||||||
|
ghostPending={session?.degraded ?? false}
|
||||||
onHandleDrag={(handle, value) => {
|
onHandleDrag={(handle, value) => {
|
||||||
const draft = draftFor(handle, value);
|
const draft = draftFor(handle, value);
|
||||||
if (draft) requester?.schedule(draft);
|
if (draft) requester?.schedule(draft);
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ interface Props {
|
|||||||
/** Live interval while a range end is dragged. */
|
/** Live interval while a range end is dragged. */
|
||||||
onRangeDrag?: (range: IntervalRangeHandle, anchor: RoadIntervalAnchor) => void;
|
onRangeDrag?: (range: IntervalRangeHandle, anchor: RoadIntervalAnchor) => void;
|
||||||
onRangeEnd?: (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({
|
export function MapCanvas({
|
||||||
state,
|
state,
|
||||||
@@ -50,6 +52,7 @@ export function MapCanvas({
|
|||||||
onHandleBlocked,
|
onHandleBlocked,
|
||||||
onRangeDrag,
|
onRangeDrag,
|
||||||
onRangeEnd,
|
onRangeEnd,
|
||||||
|
ghostPending,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const target = useRef<HTMLDivElement>(null);
|
const target = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<Map | null>(null);
|
const mapRef = useRef<Map | null>(null);
|
||||||
@@ -61,8 +64,8 @@ export function MapCanvas({
|
|||||||
visibleRef.current = visible;
|
visibleRef.current = visible;
|
||||||
// Callbacks live in refs so the map is built once and never rebuilt when a
|
// Callbacks live in refs so the map is built once and never rebuilt when a
|
||||||
// parent re-renders with new closures.
|
// parent re-renders with new closures.
|
||||||
const dragRef = useRef({ onHandleDrag, onHandleDragEnd, onHandleBlocked, onRangeDrag, onRangeEnd });
|
const dragRef = useRef({ onHandleDrag, onHandleDragEnd, onHandleBlocked, onRangeDrag, onRangeEnd, ghostPending });
|
||||||
dragRef.current = { onHandleDrag, onHandleDragEnd, onHandleBlocked, onRangeDrag, onRangeEnd };
|
dragRef.current = { onHandleDrag, onHandleDragEnd, onHandleBlocked, onRangeDrag, onRangeEnd, ghostPending };
|
||||||
const selectedRef = useRef<Road | null>(selected);
|
const selectedRef = useRef<Road | null>(selected);
|
||||||
const stateRef = useRef(state);
|
const stateRef = useRef(state);
|
||||||
const sceneRef = useRef(scene);
|
const sceneRef = useRef(scene);
|
||||||
@@ -121,6 +124,7 @@ export function MapCanvas({
|
|||||||
onRangeDrag: (range, anchor) => dragRef.current.onRangeDrag?.(range, anchor),
|
onRangeDrag: (range, anchor) => dragRef.current.onRangeDrag?.(range, anchor),
|
||||||
onRangeEnd: (range, anchor) => dragRef.current.onRangeEnd?.(range, anchor),
|
onRangeEnd: (range, anchor) => dragRef.current.onRangeEnd?.(range, anchor),
|
||||||
onBlocked: (handle, reason) => dragRef.current.onHandleBlocked?.(handle, reason),
|
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.
|
// Only the editHandles source is replaced; baseline layers are never touched.
|
||||||
handleLayerRef.current?.render(handles ?? [], ranges ?? []);
|
handleLayerRef.current?.render(handles ?? [], ranges ?? []);
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
const previewLayer = previewRef.current;
|
const previewLayer = previewRef.current;
|
||||||
const baseline = layersRef.current;
|
const baseline = layersRef.current;
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ export interface HandleDragOptions {
|
|||||||
onRangeEnd: (range: IntervalRangeHandle, anchor: RoadIntervalAnchor) => void;
|
onRangeEnd: (range: IntervalRangeHandle, anchor: RoadIntervalAnchor) => void;
|
||||||
/** A reserve handle refused the drag, with the manifest's reason. */
|
/** A reserve handle refused the drag, with the manifest's reason. */
|
||||||
onBlocked?: (handle: EditHandle, reason: string) => void;
|
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 {
|
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.
|
// 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));
|
if (state.kind === 'range') options.onRangeEnd(state.range, intervalAt(state, to));
|
||||||
else options.onEnd(state.handle, projectHandleValue(state.handle, state.origin, 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;
|
return false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,32 +21,29 @@ import { handlePositionFor } from './projection';
|
|||||||
import type { IntervalRangeHandle } from './selection';
|
import type { IntervalRangeHandle } from './selection';
|
||||||
import type { EditHandle, RoadIntervalAnchor } from './types';
|
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. */
|
/** The stretch of road an interval edit applies to. */
|
||||||
const BAND = new Style({
|
const BAND = new Style({
|
||||||
stroke: new Stroke({ color: '#29695699', width: 14 }),
|
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({
|
* Clamped turns the knob amber so "the road cannot go further" reads differently
|
||||||
stroke: new Stroke({ color: '#d49318', width: 2, lineDash: [6, 4] }),
|
* 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): Style {
|
function knob(label: string, clamped: boolean, pending: boolean): Style {
|
||||||
|
const colour = clamped ? '#d49318' : '#00a5cf';
|
||||||
return new Style({
|
return new Style({
|
||||||
image: new CircleStyle({
|
image: new CircleStyle({
|
||||||
radius: 8,
|
radius: 8,
|
||||||
fill: new Fill({ color: clamped ? '#d49318' : '#00a5cf' }),
|
fill: new Fill({ color: pending ? `${colour}66` : colour }),
|
||||||
stroke: new Stroke({ color: '#fff', width: 2 }),
|
stroke: new Stroke({ color: pending ? '#ffffff88' : '#fff', width: 2 }),
|
||||||
}),
|
}),
|
||||||
text: new Text({
|
text: new Text({
|
||||||
text: label,
|
text: pending ? `${label} 求解中…` : label,
|
||||||
offsetY: -18,
|
offsetY: -18,
|
||||||
font: '600 12px system-ui, sans-serif',
|
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 }),
|
stroke: new Stroke({ color: '#ffffffcc', width: 3 }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -55,6 +52,7 @@ function knob(label: string, clamped: boolean): Style {
|
|||||||
export class EditGhostLayer {
|
export class EditGhostLayer {
|
||||||
readonly layer: VectorLayer<VectorSource>;
|
readonly layer: VectorLayer<VectorSource>;
|
||||||
private readonly source = new VectorSource();
|
private readonly source = new VectorSource();
|
||||||
|
private pending = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.layer = new VectorLayer({
|
this.layer = new VectorLayer({
|
||||||
@@ -63,32 +61,49 @@ export class EditGhostLayer {
|
|||||||
zIndex: 110,
|
zIndex: 110,
|
||||||
style: (feature) => {
|
style: (feature) => {
|
||||||
if (feature.get('band')) return BAND;
|
if (feature.get('band')) return BAND;
|
||||||
const label = feature.get('label');
|
return knob(String(feature.get('label') ?? ''), Boolean(feature.get('clamped')), this.pending);
|
||||||
if (typeof label === 'string') return knob(label, Boolean(feature.get('clamped')));
|
|
||||||
return feature.get('clamped') ? CLAMPED : GUIDE;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows the handle at the position implied by `value`, plus a guide line back to
|
* Shows the handle at the position implied by `value`. `value` is already
|
||||||
* where it started. `value` is already clamped by `projectHandleValue`, so a
|
* clamped by `projectHandleValue()`, so a ghost pinned at the bound is the
|
||||||
* ghost pinned at the bound is the correct picture: the road cannot go further.
|
* 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 {
|
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 clamped = value >= handle.value.max - 1e-6 || value <= handle.value.min + 1e-6;
|
||||||
const delta = value - handle.value.current;
|
const delta = value - handle.value.current;
|
||||||
|
this.pending = false;
|
||||||
this.source.clear();
|
this.source.clear();
|
||||||
this.source.addFeatures([
|
this.source.addFeature(
|
||||||
new Feature({ geometry: new LineString([origin, moved]), clamped }),
|
|
||||||
new Feature({
|
new Feature({
|
||||||
geometry: new Point(moved),
|
geometry: new Point(fromLonLat(handlePositionFor(handle, value))),
|
||||||
label: `${delta >= 0 ? '+' : ''}${delta.toFixed(2)} 米`,
|
label: `${delta >= 0 ? '+' : ''}${delta.toFixed(2)} 米`,
|
||||||
clamped,
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -172,6 +172,14 @@ export interface EditStateResponse {
|
|||||||
handles: HandleManifest;
|
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
|
* `POST /api/edit-preview`. Sending `constraints` alone lets the server merge
|
||||||
* them onto the active document; `document` replaces it wholesale. `previewSeq`
|
* them onto the active document; `document` replaces it wholesale. `previewSeq`
|
||||||
@@ -182,6 +190,7 @@ export interface EditPreviewRequest {
|
|||||||
constraints?: RoadConstraint[];
|
constraints?: RoadConstraint[];
|
||||||
operations?: RoadEditOperation[];
|
operations?: RoadEditOperation[];
|
||||||
document?: RoadEditDocument;
|
document?: RoadEditDocument;
|
||||||
|
selection?: EditSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `POST /api/edits`. The version guard is what makes a second tab fail loudly. */
|
/** `POST /api/edits`. The version guard is what makes a second tab fail loudly. */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { WorkbenchState } from '../types/state';
|
|||||||
import type {
|
import type {
|
||||||
EditPreviewRequest,
|
EditPreviewRequest,
|
||||||
EditPreviewResponse,
|
EditPreviewResponse,
|
||||||
|
EditSelection,
|
||||||
EditStateResponse,
|
EditStateResponse,
|
||||||
SaveEditsRequest,
|
SaveEditsRequest,
|
||||||
SaveEditsResponse,
|
SaveEditsResponse,
|
||||||
@@ -73,7 +74,10 @@ export const api = {
|
|||||||
|
|
||||||
// Direct edit. Only reached when the `directEdit` flag is on; with it off the
|
// Direct edit. Only reached when the `directEdit` flag is on; with it off the
|
||||||
// workbench never touches these routes, so behaviour matches main exactly.
|
// 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
|
* `signal` comes from the caller's AbortController: the request layer only
|
||||||
* sends and cancels, while `EditSession` decides which responses to keep.
|
* sends and cancels, while `EditSession` decides which responses to keep.
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function handle(request, response, session) {
|
|||||||
: sendJson(response, 200, { active: false });
|
: sendJson(response, 200, { active: false });
|
||||||
if (request.method === 'GET' && url.pathname === '/api/edit-state')
|
if (request.method === 'GET' && url.pathname === '/api/edit-state')
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
.then(() => (area ? editState(area) : { active: false }))
|
.then(() => (area ? editState(area, selectionFrom(url.searchParams)) : { active: false }))
|
||||||
.then((value) => sendJson(response, 200, value))
|
.then((value) => sendJson(response, 200, value))
|
||||||
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
|
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
|
||||||
if (request.method === 'POST' && url.pathname === '/api/edit-preview')
|
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 };
|
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 workspace = path.dirname(area.input);
|
||||||
const initialized = ensureRevisionStore(workspace, area.nativeRoad);
|
const initialized = ensureRevisionStore(workspace, area.nativeRoad);
|
||||||
const directEdits = loadEditDocument(initialized.paths.activeEdits);
|
const directEdits = loadEditDocument(initialized.paths.activeEdits);
|
||||||
@@ -288,6 +301,7 @@ function editState(area) {
|
|||||||
const resolution = resolveDirectEditConstraints(model, directEdits, {
|
const resolution = resolveDirectEditConstraints(model, directEdits, {
|
||||||
revisionId: initialized.active.activeRevisionId,
|
revisionId: initialized.active.activeRevisionId,
|
||||||
compilerGeometryVersion: GEOMETRY_VERSION,
|
compilerGeometryVersion: GEOMETRY_VERSION,
|
||||||
|
selection,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
active: true,
|
active: true,
|
||||||
@@ -323,6 +337,7 @@ function editPreview(area, body = {}) {
|
|||||||
revisionId: activeState.activeRevisionId,
|
revisionId: activeState.activeRevisionId,
|
||||||
previewSeq,
|
previewSeq,
|
||||||
compilerGeometryVersion: GEOMETRY_VERSION,
|
compilerGeometryVersion: GEOMETRY_VERSION,
|
||||||
|
selection: selectionFrom(body.selection),
|
||||||
});
|
});
|
||||||
const compiled = compileGeometry(model, loadOverrides(area.outputs.nativeRoadOverrides), {
|
const compiled = compileGeometry(model, loadOverrides(area.outputs.nativeRoadOverrides), {
|
||||||
edgeLines: area.nativeRoad.edgeLines,
|
edgeLines: area.nativeRoad.edgeLines,
|
||||||
|
|||||||
Reference in New Issue
Block a user