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

@@ -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 segments 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 nodes handles');
assert.ok(
junctionScoped.every((handle) => handle.kind.startsWith('junction-') && handle.anchor.nodeId === 'junction'),
'a junction selection must only carry that nodes 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' },