Commit Graph

58 Commits

Author SHA1 Message Date
1917ae91ff chore(task): link crosswalk editor experiment 2026-08-28 17:58:12 +08:00
a0af1c0e84 chore(task): plan crosswalk element editor experiment 2026-08-28 17:57:37 +08:00
b2da3b6866 chore: record journal 2026-08-28 17:45:38 +08:00
aaeb3b9f18 chore(task): archive junction-control-offsets 2026-08-28 17:45:38 +08:00
11f4d748a7 fix: expose and apply control marking handles 2026-08-28 17:45:03 +08:00
7c45c21f8c 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>
2026-08-28 16:58:31 +08:00
c7425f5ed4 feat: make crosswalk and stop-line offsets solvable
Step 1 of the control-marking task, and deliberately server-only: no handle is
drawn yet. This project already shipped a range handle for `profile.interval`,
which compileGeometry ignores, so the control dragged and changed nothing. The
consumer comes first now.

Two kinds join the taxonomy — `junction-crosswalk-inset` and
`junction-stop-line-offset`, both on the existing `junction-approach` anchor. The
solver writes them onto the approach entry, `applyDirectJunctionPlans` carries
them onto the compiled approach, and `compileControlMarkings` reads them in place
of the module constants it used for every junction. They move markings without
reshaping the junction, so unlike width and cutback they deliberately do not
trigger a boundary recompute.

`applyJunctionConstraint` becomes an explicit switch. Its trailing `else` had
meant every kind that was not approach-width fell through to the cutback
validator, so a new kind would have been silently validated and written as a
cutback. The same non-exhaustive shape in the test fixture's `valueFor` is fixed
the same way, and now throws for an unnamed kind rather than answering with a
corner radius.

design.md's taxonomy is updated with it — a test asserts the two cannot drift,
which is what caught the omission.

Measured on a 41-road workspace with 8 crossings: both constraints change their
marking geometry, neither drags the other, and out-of-range blocks instead of
clamping. That measurement is not in the suite: the synthetic junction resolves
`junction_inset_m` to 0 because its crossing never binds to a plan, and the
committed OSM fixture has no crossings at all. The tests assert the wiring the
handles will depend on — values reaching the approach entry, distinct branches,
blocking diagnostics — and the gap is recorded in the test itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 15:01:36 +08:00
813809d25e chore(task): archive 08-28-edit-interaction-polish 2026-08-28 12:32:57 +08:00
5292c00926 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>
2026-08-28 12:26:15 +08:00
d893e406d6 perf: stop recomputing control bounds in overlap tests
compileGeometry took 1575 ms on a 41-road area and grew superlinearly — 6x the
time for 2x the roads — while feature count grew linearly. Profiling put 84% of
it in three adjacent lines: `bounds` alone was 821 ms, `ringsOverlap` 258 ms, and
the intermediate arrays cost another 163 ms of GC.

The callers walk every lane in 0.25 m steps and test each step's rectangle
against every control feature, which is millions of calls on a small area. Each
one recomputed the control's bounding box from scratch through four
`Math.min(...ring.map(...))` spreads, allocating four arrays per call for a box
that never changes.

Control rings are the same array objects for the whole compile, so their bounds
are now computed once into a WeakMap, candidate bounds are taken in a single
allocation-free pass, and the cheap rejection moved up into `ringsOverlapControl`
so the exact test only runs for boxes that actually touch.

1575 ms -> 104 ms, and the per-road cost is flat instead of rising, so this now
extends to city-scale areas rather than degrading quadratically. Output is
unchanged, which the fixture baselines prove.

Found because a preview felt slow to drag. The measurement mattered more than the
reading did: the nested `model.roads.find()` calls that looked like the culprit
were not, and building the scoped-compile architecture first would have left this
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 11:41:39 +08:00
e08af11b7d docs: record the direct-edit client and side conventions
Three of this task's defects were cross-layer wiring rather than logic, so they
belong in the spec where the next session will read them, not only in the task's
research notes.

Frontend: a new client spec covering layer ownership (baseline is never written,
preview hides rather than overwrites), why a handle carries only its id, why a
handle's position must derive from the clamped value, the ban on computing inside
a setState updater, and why per-pointermove React state is the wrong owner of
per-frame feedback.

Backend: the left/right convention now states the three places that must agree
and why a sign convention can be wrong on both sides of the wire at once; a table
of the four profile fields the geometry stage actually reads, so nobody builds UI
for interval-scoped editing again before the compiler honours it; and the
save-is-not-applying contract, including that compileFresh must be installed by
every path that sets session.area.

Also records that an identity test cannot catch an ignored editsFile, and that a
regression test is only trustworthy once it has been seen to fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 10:27:04 +08:00
4ef7b478b2 chore(task): archive 08-26-direct-edit-map-editor 2026-08-28 10:25:42 +08:00
ef55556f19 chore(task): archive 08-27-junction-dominated-roads 2026-08-28 10:19:25 +08:00
62795a97b9 fix: install the recompile hook on UI import
`/api/import` set `session.area` but never `session.context.compileFresh`, which
is only wired when the server starts with an area on the command line. Every
session imported through the browser therefore answered "请先导入 OSM 文件" to
`/api/compile`, even though the import had just succeeded.

This predates direct editing — it broke "保存并重新生成" for UI-imported
workspaces — but it surfaced now, because saving a direct edit recompiles and so
reported a failed regeneration after a save that had in fact succeeded.

Covered by a new HTTP-level test: import the fixture, then recompile twice. The
bug lived in the wiring between two handlers rather than in either one, so
nothing below the HTTP boundary could catch it. Verified by reverting the fix and
watching the test fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:37:27 +08:00
d94c546450 fix: recompile after saving direct edits
Saving wrote the v2 document but nothing else, and `/api/state` serves the
outputs of the last compile — so a refresh showed the pre-edit geometry and a
successful save was indistinguishable from a failed one. Reported as "saved,
refreshed, edit gone"; the document on disk was in fact correct, resolving as
`exact` with the constraint reaching the profile.

Save now recompiles and clears the preview overlay, since the baseline carries
the edit afterwards and would otherwise draw it twice. The recompile is reported
separately from the save: if it fails the message says the edit was saved and
names the compile error, rather than claiming the save failed.

The existing identity test could not have caught this. An ignored `editsFile`
and an empty document produce the same output, so it proved nothing about a
non-empty one. The API test now compiles with and without a saved constraint and
asserts the outputs differ.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:25:09 +08:00
38593f6f67 feat: save, undo and redo direct edits
Step 5 of the map editor. EditSession becomes the single owner of the constraint
set, replacing the pair of refs step 4 kept alongside it: it now holds the saved
operations too, and `fragment()` assembles the constraints plus every operation
they reference — the shape both preview and save send. That removes the class of
bug that produced the earlier 400, because callers can no longer ship a
constraint whose provenance points at nothing.

Save goes through `expectedDocumentVersion`. A 409 adopts the server's version so
the next attempt is checked against reality, and says which version won instead
of failing silently. Undo of a saved gesture appends an inverse operation rather
than rewriting persisted history; undo of an unsaved one just moves the cursor.
Discard drops unsaved commands, cancels anything in flight, and returns the map
to the baseline.

One gesture mints one operation id, released on pointerup. Reusing an id across
gestures put two operations with the same id in the document, which
validateEditDocument() rejects.

The API test now covers the payload the client actually sends — `constraints` +
`operations` merged into the active document, rather than a whole document — and
asserts that a save survives a reload as `exact`. Sending constraints without
their operations is asserted to be rejected rather than written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:11:52 +08:00
92297270f1 feat: drag road handles with ghost and server preview
Three things, kept in one commit because they touch overlapping hunks of the
same two files and this environment has no interactive hunk staging. Splitting
them by file would have drawn boundaries that misrepresent what changed.

1. Step 4 of the map editor. A native OpenLayers PointerInteraction turns a
   drag into a clamped constraint value, the ghost source shows it immediately,
   and the solver's answer replaces a parallel set of preview layers while the
   baseline layers are hidden rather than overwritten. Preview requests debounce
   at 80 ms, pointerup flushes without waiting, and a newer request aborts the
   one in flight; EditSession decides which answers count. Handle positions come
   from the clamped value, so a handle stops at its limit instead of following
   the cursor. Three of the four drag capabilities are live: edge offset,
   sidewalk width, lane divider.

2. Road edge handles were drawn on the wrong side. offsetLine() offsets
   counter-clockwise from the direction of travel and sidewalks use
   `heading + (side === 'left' ? -90 : 90)`, so left is `tangent - 90`;
   makeRoadHandles() placed the left handle at `tangent + 90`, over the right
   kerb. Dragging the visually-left handle moved the right edge. Fixed on both
   sides of the wire, with regression tests that name the sides geographically
   rather than by axis sign.

3. Roads the junctions geometrically fill are now read-only. The 0.45 cap per
   reserve made the existing `unavailable` branch unreachable, so a 14.5 m stub
   between two junctions was offered a 1.5 m editable band with no room for the
   transitions a road-interval constraint needs. Greying only affects the
   manifest: constraints already saved against such a road keep being solved, so
   the geometry output is unchanged and the fixture baselines do not move.

Range handles are built and unit-tested but hidden behind
`intervalEditingSupported`: compileGeometry() reads neither profile.interval nor
profile.transitions, so every edit applies to the whole road and the control
would have had no effect. Recorded in research/interval-not-applied.md, which
also blocks one PRD acceptance criterion.

The ol-ext probe stays in the tree as a manual harness; ol-ext is still not a
dependency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:38:34 +08:00
bc4b9a9717 feat: add direct edit handles behind directEdit flag
Steps 1-3 of the main map road interval editor.

EditSession keeps the command stack, undo/redo and previewSeq arbitration as
pure logic with no OpenLayers reference, so all of it is unit-tested in node.
Pointer displacement converts to meters through EPSG:4326 and spherical
distance: treating a 3857 delta as meters desyncs the geometry from the cursor
by 1/cos(latitude). Handle drags project onto the axis the manifest declares
and clamp to its range, so the client never writes a coordinate into a road
polygon.

All of it sits behind a directEdit flag that defaults to off. With the flag off
the workbench requests no manifest, creates no extra source and registers no
interaction, so behaviour matches main.

The ol-ext probe passed its three gates but is not adopted for road handles.
Transform translates by the raw pointer delta, so a handle detaches from its
clamped constraint value: a drag reading -24.1 m produced a draft of -5.4 m.
Production needs the handle position derived from the constraint instead, which
means owning the position update, so native OL PointerInteraction will carry
the drag. ol-ext stays out of package.json; the probe is kept as a manual
harness. Reserve handles are unreachable with the current solver, recorded in
research/ rather than worked around.

Also names the dead backend when an API response is empty, instead of
surfacing "Unexpected end of JSON input" from response.json().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 14:59:11 +08:00
e25c564bb9 chore: record journal 2026-08-27 13:30:19 +08:00
2d85211558 chore(task): archive 08-26-direct-edit-solver-api 2026-08-27 13:29:18 +08:00
a26d4be666 docs: document direct edit solver and API contracts 2026-08-27 13:25:50 +08:00
f3f763f94c feat: persist direct edits with revision rebase 2026-08-27 12:52:40 +08:00
41c314fc61 feat: add direct edit preview APIs 2026-08-27 12:48:29 +08:00
735ce69d26 feat: classify direct edit replay states 2026-08-27 12:43:35 +08:00
94e319c717 chore: record direct edit solving progress 2026-08-27 12:37:32 +08:00
0eb404d8f7 feat: solve direct edit constraints 2026-08-27 12:36:35 +08:00
67e67e66bf chore: record direct edit manifest progress 2026-08-27 12:19:54 +08:00
ab63d23896 feat: generate direct edit handle manifest 2026-08-27 12:18:11 +08:00
3297d0c97a feat: insert direct edit constraint solver stage
Adds resolveDirectEditConstraints between compileRoadModel and
compileGeometry, per the parent design's data flow. The stage is an
identity transform until the individual constraint kinds land: it
returns empty road profiles, junction plans and handle manifest, so
compileGeometry's geometry code is untouched and output stays
byte-identical.

Constraint planning is separated from solving up front. Only `exact`
and `recheck` constraints may reach the solver; `disabled`,
`pending`, `conflicted` and `stale` are reported as unapplied with a
reason. SOLVED_KINDS starts empty and grows one entry per kind, so a
partially delivered solver reports what it skipped instead of
publishing half-solved geometry.

diagnostic() moves to src/compile/diagnostics.js so the solver can
share the compiler's diagnostic shape without depending on fs, which
would break the pure-function boundary that lets preview, the full
compile and the CLI export share one implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 10:29:45 +08:00
c5e762e1a9 chore: record journal 2026-08-27 09:06:40 +08:00
98e8a770a3 chore(task): archive 08-26-direct-edit-documents 2026-08-27 09:01:21 +08:00
ba8eaba9d9 feat: compile revisions from area config snapshots 2026-08-26 18:08:55 +08:00
c0c8a16dd4 feat: add immutable road revision storage 2026-08-26 17:54:38 +08:00
35002371ae feat: add native road edits document validation 2026-08-26 17:46:02 +08:00
93f09e399e chore(road-editor): plan direct-edit task tree and add client test infra
Planning: parent design.md becomes the single authoritative contract
(constraint model with 6 kinds, handle manifest, coordinate/unit
layering, preview sequencing, storage layout and lazy migration, area
config snapshot, API contract). Work is split into four independently
verifiable child tasks with per-step gates and rollback points.

Test infra: pin vitest 4.1.11, add test:client:unit for client pure
logic, extend prettier globs to root *.ts so vitest.config.ts is checked.

Add .gitignore: the repo had none, so inputs/, outputs/, workbench-data/
and the client build output were untracked rather than ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 17:33:42 +08:00
7dc7ede2b8 chore(task): archive 08-26-workbench-selection-save-fix 2026-08-26 15:39:18 +08:00
02c3a4d3e4 fix: avoid map refresh on road selection 2026-08-26 15:39:17 +08:00
89e0a9ce37 chore(task): archive 00-bootstrap-guidelines 2026-08-26 15:29:12 +08:00
faaa33717f docs: add frontend development guidelines 2026-08-26 15:29:11 +08:00
f7601d554e chore(task): archive 08-26-workbench-layer-scene-polish 2026-08-26 15:26:42 +08:00
3c5f39ed73 fix: refine workbench map layers 2026-08-26 15:26:41 +08:00
2f2bd6cbf0 chore(task): archive 08-26-scene-preview-fix 2026-08-26 15:09:27 +08:00
4133a5244a fix: restore scene preview styles 2026-08-26 15:09:27 +08:00
89b402de7a chore(task): archive 08-26-react-workbench-migration 2026-08-26 15:02:14 +08:00
3ddb33e321 feat: migrate workbench to React 2026-08-26 15:01:07 +08:00
f15e69c868 chore: archive web OSM import task 2026-08-26 14:06:27 +08:00
181e575e1e feat: add web OSM import workflow 2026-08-26 14:05:26 +08:00
81e02c670d feat: export portable native road packages v0.3.0 2026-08-26 11:59:48 +08:00
3b4befb025 fix: preserve native road metadata order v0.2.2 2026-08-26 10:44:17 +08:00
54d77ef09d fix: preserve native road render order v0.2.1 2026-08-26 10:25:08 +08:00