airport-editor: select-mode drag status tip and design note

Surface selection shows a short tip to drag handles. Promote the vertex-drag
design doc and note rail-only vertex delete vs Del deleting the surface.
This commit is contained in:
Reese Norris
2026-07-27 14:48:18 -04:00
parent f2dd5a609c
commit e33ca238f6
3 changed files with 1007 additions and 0 deletions

View File

@@ -0,0 +1,991 @@
# Airport Editor: Reliable Vertex Drag (Bugfix + UX Polish)
| Field | Value |
|-------|--------|
| **Document** | Reliable vertex drag for `/airport-editor` polylines and points |
| **Author** | _(design author / implementer)_ |
| **Date** | 2026-07-27 |
| **Status** | **Implemented** (PR1: live geometry / handles / click suppress; PR2: select-mode status tip + this design note) |
| **Parent design** | [`docs/design/apt-air-editor.md`](apt-air-editor.md) (Implemented) |
| **Scope** | Bugfix + minimal UX polish — **not** a GIS rewrite |
---
## Overview
> **Closeout (2026-07-27):** Code for the fix landed in `map-layers.js` / `main.js` / `airport-editor.css` with pure tests under `webjs/airport-editor/`. Select-mode surface selection flashes a one-line tip to drag white handles. Design history below is retained as the engineering record (root causes, state machine, checklist).
The airport editors **vertex drag** path was partially wired but failed as a product experience. Users could select a runway/taxiway/hold/parking surface and see vertex chrome, yet **dragging nodes did not feel like it worked**: the handle may move while the underlying geometry freezes, hit targets are small and visually double-rendered, and drag-end can race with map-click deselection and a full overlay rebuild.
This design is a **focused fix** in the existing Leaflet PE stack:
1. Make **select → drag vertex** rock solid for multi-point polylines and single-point parking/hold.
2. **Live-update** the surface layer while the handle moves (parent design already required this).
3. Slightly **simplify interaction feel** (larger targets, no dual handles, robust drag-end) without new modes or libraries.
4. Keep draw tools, rail inspector, insert/delete vertex, aircraft drag, Blob download, dirty tracking, and boring-web constraints intact.
Primary touch surfaces: `map-layers.js` (`OverlayController`), `main.js` (drag callbacks / refresh lifecycle), `airport-editor.css`, and pure Node tests under `webjs/airport-editor/`.
---
## Background & Motivation
### Current architecture (relevant slice)
```mermaid
flowchart LR
User["User pointer"] --> Map["Leaflet map"]
Map --> OC["OverlayController<br/>map-layers.js"]
OC -->|"onSelect / onVertexDrag*"| Main["main.js"]
Main -->|"setVertex / refresh"| Model["model.js document"]
Main -->|"overlays.render"| OC
Main -->|"rail.render"| Rail["ui-rail.js"]
```
| Piece | Path | Role today |
|-------|------|------------|
| Overlay render + handles | `internal/web/static/js/openfsd/airport-editor/map-layers.js` | `OverlayController.render`, `_addVertexHandles`, drag listeners |
| Edit loop | `…/main.js` | `onVertexDrag` / `onVertexDragEnd``setVertex``afterAptMutation``refresh` |
| Document mutations | `…/model.js` | `setVertex`, `insertVertex`, `deleteVertex` (pure, tested) |
| Handle CSS | `internal/web/static/css/openfsd/airport-editor.css` | `.apted-vertex-handle` 12×12 + margins |
| Pure tests | `webjs/airport-editor/map-layers.test.js`, `model.test.js` | No real Leaflet drag simulation |
Parent design (`apt-air-editor.md`) already specified:
- **Move vertex** = select surface → drag handle (Leaflet marker).
- **Performance**: *“avoid full map rebuild on single vertex drag (update that latlng only).”*
- **Delete vertex** (parent table): “Select handle + Delete key” — **not implemented**; current `main.js` Del/Backspace deletes the **whole surface**. This fix does not close that product gap; vertex delete stays rail-only (see Open Questions / Known gaps).
Implementation shipped the select/handle scaffolding but **did not complete live geometry update**, and several Leaflet interaction details undermine reliability.
### Verified root causes (from source — not the suspect list alone)
#### RC1 — Live polyline/point update is missing (confirmed)
```115:122:internal/web/static/js/openfsd/airport-editor/main.js
onVertexDrag(si, vi, lat, lon) {
setVertex(doc, si, vi, { lat, lon });
// Live-update surface polyline without full refresh of handles mid-drag.
// Full refresh on dragend.
},
onVertexDragEnd(si, vi, lat, lon) {
setVertex(doc, si, vi, { lat, lon });
afterAptMutation();
```
- Comment promises live update; **code only mutates the model**.
- Leaflet moves the **handle marker** on drag, so something on screen moves — but `_addSurface`s `L.polyline` / parking `L.circleMarker` is **not** updated until `onVertexDragEnd` → `afterAptMutation` → `refresh` → full `overlays.render`.
- Aircraft drag works better because the aircraft **is** the draggable marker; surface geometry is a **separate** path layer under the handle.
- Severity: **High** — primary “drag doesnt work” feel (rubber-band line frozen; parking disc stuck while ghost handle floats).
#### RC2 — Dual handle construction (visual + interaction confusion)
```555:599:internal/web/static/js/openfsd/airport-editor/map-layers.js
_addVertexHandles(surface, surfaceIndex) {
// ...
const marker = L.circleMarker([p.lat, p.lon], { radius: 6, /* ... */ });
const handle = L.marker([p.lat, p.lon], {
draggable: true,
zIndexOffset: 2000,
icon: L.divIcon({
className: 'apted-vertex-handle',
iconSize: [12, 12],
iconAnchor: [6, 6],
}),
// ...
});
// dragstart / drag / dragend on handle only
handle.addTo(this.vertexGroup);
// Keep circle under for visibility if divIcon fails in tests.
marker.addTo(this.vertexGroup);
```
- Two layers per vertex: non-draggable `circleMarker` (overlay pane) + draggable `divIcon` marker (marker pane).
- Marker pane is above overlay pane (`leaflet.css` z-index 600 vs 400), so the circle usually does **not** steal pointer events — but the user sees **two dots** (or a thick composite), and the intentional “keep circle under” comment is misleading (add order does not change pane stacking).
- Severity: **Medium** — confuses targeting; doubles layer churn; zero benefit in production.
#### RC3 — CSS double-offset on the hit target
```256:267:internal/web/static/css/openfsd/airport-editor.css
.apted-vertex-handle {
width: 12px !important;
height: 12px !important;
margin-left: -6px !important;
margin-top: -6px !important;
border: 2px solid #4a7ab0;
/* ... */
cursor: move;
}
```
- Leaflet already positions via `iconAnchor: [6, 6]` on a 12×12 icon.
- Extra `margin-left/top: -6px` shifts the **visual** handle northwest of the true lat/lon while the drag anchor remains on the vertex.
- Result: handle appears off-vertex; easy to miss the interactive box; looks “broken” even when drag technically starts.
- Severity: **High** for perceived accuracy / grab reliability — **must ship with the live-geometry fix**, not a follow-up PR (see PR Plan).
#### RC4 — Full `render` on dragend rebuilds everything (including handles)
```412:432:internal/web/static/js/openfsd/airport-editor/map-layers.js
render(airport, aircraft, selection = null) {
// ...
this.group.clearLayers();
this.vertexGroup.clearLayers();
// ... re-add all surfaces, aircraft, then handles if surface selected
}
```
- Mid-drag: no full refresh (good) — and **must stay that way** via caller contract (`onVertexDrag` never refreshes).
- Dragend: `afterAptMutation` → `refresh` clears **all** layers and rebuilds — **required and allowed** once the pointer is up (`_dragging === false`).
- Parent performance note already forbids full rebuild *during* drag; live path should use `_layerByKey` + `setLatLngs` / `setLatLng`.
- Severity: **Medium** for post-drag jank; **High** if anything calls `refresh` while the pointer is still down (`_dragging === true`).
#### RC5 — Map click vs drag-end race (intermittent deselect risk)
```392:397:internal/web/static/js/openfsd/airport-editor/map-layers.js
map.on('click', (ev) => {
if (this._dragging) return;
// ...
});
```
```588:594:internal/web/static/js/openfsd/airport-editor/map-layers.js
handle.on('dragend', (ev) => {
// ...
setTimeout(() => {
this._dragging = false;
}, 50);
});
```
```137:142:internal/web/static/js/openfsd/airport-editor/main.js
if (doc.mode === MODE_SELECT) {
if (doc.selection) {
doc.selection = null;
refresh();
}
```
- Select mode: empty-map click clears selection.
- Authors already left a **50ms** `_dragging` hold after `dragend` — evidence they anticipated residual clicks, not a measured production failure rate.
- On **desktop**, `click` often does **not** fire when mousedown target (handle) differs from mouseup target; Leaflets own map-drag click suppression does **not** automatically cover **marker** drag. Residual deselect is therefore an **intermittent** risk (touch / synthesized clicks / browser quirks), not a proven always-on bug from source alone.
- Severity: **Medium** (defensive hardening). Extending to ~250ms via a dedicated `dragEndedAt` timeline (separate from pointer-down `_dragging`) + `bubblingMouseEvents: false` + stopPropagation is the mitigation. Old mobile ~300ms synthetic click is the upper-bound rationale for the 250400ms band (Open Question 1).
#### RC6 — Vertices only after selection (by design, but two-step)
```428:432:internal/web/static/js/openfsd/airport-editor/map-layers.js
if (this.editable && this._selection?.type === 'surface') {
const s = surfaces[this._selection.index];
if (s) this._addVertexHandles(s, this._selection.index);
}
```
- Matches parent UX table (“Select surface → drag handle”).
- User ask (“point/click/drag existing nodes on any visible polyline”) can be read as wanting **zero-step** grab-anywhere-vertices. That is a larger product change (clutter at 200 surfaces).
- This design: **keep select-then-drag**, make it obvious and reliable; optional light polish only (status tip on select). Always-on vertices for every surface is a **non-goal**.
#### RC7 — Small hit target (12×12)
- 12 CSS px is below common 2444px target guidelines; border eats interior.
- Severity: **LowMedium** alone; stacks with RC3. Ship together with CSS alignment in the same fix PR.
#### RC8 — `editable` flag
- `this.editable = opts.editable !== false` in `OverlayController` constructor; `main.js` does not pass `editable: false`.
- **Not** a current bug. Keep flag for tests/read-only demos.
### Pain points (user-facing)
1. Select a taxiway/runway → handles appear → drag → line does not follow.
2. Handles look misaligned / doubled; hard to grab.
3. Sometimes after release, selection/handles disappear (intermittent).
4. Interaction feels “mode-heavy” even though Select is already the right mode — friction is broken feedback, not missing modes.
---
## Goals & Non-Goals
### Goals
1. **Reliable vertex drag** for selected surfaces:
- Multi-point: `RUNWAY`, `TAXIWAY`, multi-point `HOLD` polylines.
- Single-point: `PARKING` and single-point `HOLD` (circleMarker geometry follows).
2. **Live geometry**: while dragging a handle, the corresponding surface layer tracks the pointer (no frozen rubber band).
3. **Stable drag-end**: model committed, dirty/validation/titlebar updated, selection **retained**, handles remain for further edits; full `render` **does** run on dragend.
4. **Simpler feel** without new tools: single handle per vertex, larger hit target, correct alignment, status copy that names the select→drag flow.
5. **Preserve** draw modes, select mode, rail inspector (insert/delete vertex, props), aircraft drag, Blob download, dirty hash / `beforeunload`, server validate, import-graph and package layout.
6. **Tests**: pure Node tests for extractable logic (including live-layer apply helper + suppress matrix + handle-options symmetry); documented **manual smoke** for Leaflet drag. No Playwright/Cypress by default (house policy).
7. **Scope small**: fix `map-layers.js` + `main.js` (+ CSS/tests) in a **user-complete** fix PR; no new packages, no leaflet-draw.
### Non-Goals
- Always-visible vertices on every unselected surface / “grab any node without selecting.”
- Mid-segment insert handles on the map (rail “Add vertex” remains).
- Snap-to-grid, snap-to-nearby-vertex, topology merge of taxi intersections.
- Vendoring `leaflet-draw` / `leaflet-geoman` / any new map framework.
- Touch-first mobile authoring optimization.
- Browser automation suite (Playwright etc.) unless product owner overrides house policy after complexity-gate write-up.
- Server-side persistence of APT/AIR.
- Changing wire format (`pkg/twrfiles`) or Go validation semantics.
- Reworking draw-tool click/dblclick finish.
- **Vertex-level Delete key** (parent table claim); stays rail-only for this fix.
---
## Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| **K1. Interaction model** | Keep **select surface → drag handles**; do not show all vertices always | Matches parent design; avoids clutter at sweatbox-scale airports (~200 surfaces); broken feedback is the real bug |
| **K2. Live update ownership** | Overlay **paints first** from handle latlng (order-independent), then `onVertexDrag` updates model; no full `render` mid-drag | Encapsulates Leaflet; model remains source of truth on commit |
| **K3. Single handle widget** | One `L.marker` + `divIcon` per vertex; **remove** dual `circleMarker` | Eliminates double-dot UX; marker pane already correct for drag |
| **K4. CSS positioning** | Size via `iconSize` / `iconAnchor` only; **no** compensating negative margins; ship in same PR as live geometry | Fixes high-severity RC3 grab misalignment with the rest of the fix |
| **K5. Drag-end state machine** | `_dragging` = pointer-down only; clear **before** `onVertexDragEnd`; post-drag map-click shield is **`dragEndedAt` only** via `shouldSuppressMapClick` | Full `refresh`/`render` after dragend must not be blocked; do not overload `_dragging` as the post-drag suppress flag |
| **K6. No new library** | Stay on in-tree Leaflet only | Parent design + complexity gate; custom handles are enough once live update works |
| **K7. Testing strategy** | Pure helpers (incl. mock-layer apply + handle options) + manual smoke; no Playwright | House default; drag is inherently browser/Leaflet |
| **K8. Parking/hold points** | Same vertex-handle path; surface layers are polyline **or** circleMarker only | One code path; explicit layer-type contract |
| **K9. Mid-drag render** | **Hard invariant**: callers must not `render`/`refresh` while `_dragging`; **omit** soft early-out | Simpler than cache-only early-out; `onVertexDrag` already forbids refresh; avoids dropping `_selection` |
---
## Proposed Design
### Target interaction (happy path)
```mermaid
sequenceDiagram
participant U as User
participant M as Map / OverlayController
participant App as main.js
participant Doc as model (doc)
U->>M: Click polyline (select mode)
M->>App: onSelect(surface i)
App->>Doc: selection = surface i
App->>M: render(..., selection)
M-->>U: Highlight + vertex handles
U->>M: Pointer down on handle + drag
M->>M: _dragging = true
loop each drag event
M->>M: paint setLatLngs/setLatLng (overlay-local pts)
M->>App: onVertexDrag(i, vi, lat, lon)
App->>Doc: setVertex (aptDirty) — no refresh
end
U->>M: Pointer up (dragend)
M->>M: _dragEndedAt = now; _dragging = false
M->>App: onVertexDragEnd(...)
App->>Doc: setVertex
App->>App: afterAptMutation → validate + refresh
Note over M: Full render runs because _dragging is false
M-->>U: Geometry committed; handles still present
Note over M: Map click suppressed via dragEndedAt<br/>for MAP_CLICK_SUPPRESS_MS only
```
### Normative drag / click / render state machine
**Single contract — implement exactly this; do not mix with alternate suppress schemes.**
| Field | Meaning |
|-------|---------|
| `_dragging` | `true` only while a vertex (or aircraft) **pointer drag is active** (after `dragstart`, until `dragend` clears it). |
| `_dragEndedAt` | `number` ms timestamp of last dragend, or `0` if never / reset on next dragstart. Used **only** for map-click suppress. |
| `MAP_CLICK_SUPPRESS_MS` | Default `250`. Duration after `_dragEndedAt` during which map clicks are ignored. |
**Transitions:**
1. **`dragstart`**: `_dragging = true`; `_dragEndedAt = 0`.
2. **`drag`**: live-paint surface layer; call `onVertexDrag` (model only — **no** `refresh`).
3. **`dragend`** (order is normative):
1. Read final latlng from handle.
2. `_dragEndedAt = Date.now()`.
3. `_dragging = false` ← **before** app callback so full rebuild is allowed.
4. `onVertexDragEnd(si, vi, lat, lon)` → `setVertex` + `afterAptMutation` → `refresh` → `overlays.render` **must proceed**.
4. **Map `click`**: only gate is `shouldSuppressMapClick({ dragging: this._dragging, dragEndedAt: this._dragEndedAt, suppressMs: MAP_CLICK_SUPPRESS_MS }, Date.now())`. Do **not** keep `_dragging` true after pointer-up to “cover” residual clicks.
5. **`render`**: full rebuild always when called. Callers **must not** invoke `render`/`refresh` while `_dragging === true`. No soft early-out (see §6).
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Dragging: dragstart\n_dragging=true
Dragging --> Idle: dragend\n_dragEndedAt=now\n_dragging=false\nthen onVertexDragEnd
note right of Idle
Map click suppressed if
now - _dragEndedAt < suppressMs
Full render always OK
end note
note right of Dragging
Live setLatLngs only
No refresh/render
end note
```
### 1. Fix handle rendering (`_addVertexHandles`)
**Remove** the non-draggable `circleMarker` companion. Export pure options for tests:
```js
/** Keep in sync with .apted-vertex-handle width/height in airport-editor.css */
export const VERTEX_HANDLE_PX = 16;
/**
* Pure: options for the vertex L.marker (no Leaflet instance required).
* @param {number} vertexIndex
* @returns {object}
*/
export function buildVertexHandleOptions(vertexIndex) {
const px = VERTEX_HANDLE_PX;
return {
draggable: true,
autoPan: false,
keyboard: false,
zIndexOffset: 2000,
bubblingMouseEvents: false,
title: `Vertex ${vertexIndex + 1}`,
// icon constructed at call site with L.divIcon({...buildVertexHandleIconOptions()})
};
}
/** Pure icon size/anchor for divIcon — symmetry asserted in unit tests. */
export function buildVertexHandleIconOptions() {
const px = VERTEX_HANDLE_PX;
return {
className: 'apted-vertex-handle leaflet-interactive',
iconSize: [px, px],
iconAnchor: [px / 2, px / 2],
};
}
```
Normative construction:
```js
const handle = L.marker([p.lat, p.lon], {
...buildVertexHandleOptions(vi),
icon: L.divIcon(buildVertexHandleIconOptions()),
});
// Only this handle — no companion circleMarker.
handle.addTo(this.vertexGroup);
```
Notes:
- Explicit `leaflet-interactive` is belt-and-suspenders with Leaflets marker interactive default (`pointer-events` rules in `leaflet.css` lines 242256).
- `autoPan: false` prevents surprising map pans when dragging near edges during precise geometry work.
- **No dual handle.** Regression tests assert `buildVertexHandleIconOptions` has symmetric `iconSize`/`iconAnchor` and that handle construction no longer adds a second layer factory (see Testing).
### 2. Live geometry update (core fix)
#### Layer-type contract (normative)
Surfaces registered under `_layerByKey` key `surface:${index}` are **exactly one of**:
| Kind / points | Layer type | Live API |
|---------------|------------|----------|
| Multi-point runway / taxiway / hold | `L.polyline` | `setLatLngs(latlngs)` |
| Parking, or hold with 1 finite point | `L.circleMarker` | `setLatLng(latlng)` |
They must **not** be wrapped in a `FeatureGroup`, `L.marker`, or other container. A future refactor that wraps surface layers would silently break live update if this contract is ignored — preserve the flat registration in `_addSurface`.
#### Pure helpers
```js
/**
* Pure: points → Leaflet latlng tuples (finite only).
* @param {{lat:number,lon:number}[]} points
* @returns {Array<[number, number]>}
*/
export function pointsToLatLngs(points) {
const out = [];
if (!Array.isArray(points)) return out;
for (const p of points) {
if (Number.isFinite(p?.lat) && Number.isFinite(p?.lon)) {
out.push([p.lat, p.lon]);
}
}
return out;
}
/**
* Pure: apply latlngs to a surface layer duck-typed like Leaflet polyline/circleMarker.
* Prefer setLatLngs (polyline) then setLatLng (circleMarker). No-op if neither.
* @param {{ setLatLngs?: Function, setLatLng?: Function }|null|undefined} layer
* @param {Array<[number, number]>} latlngs
* @returns {'polyline'|'point'|'none'}
*/
export function applySurfaceLatLngs(layer, latlngs) {
if (!layer || !latlngs || latlngs.length === 0) return 'none';
if (typeof layer.setLatLngs === 'function') {
layer.setLatLngs(latlngs);
return 'polyline';
}
if (typeof layer.setLatLng === 'function') {
layer.setLatLng(latlngs[0]);
return 'point';
}
return 'none';
}
/**
* Pure: patch one vertex in a points array (immutable-style new array).
* @param {{lat:number,lon:number}[]} points
* @param {number} vertexIndex
* @param {number} lat
* @param {number} lon
*/
export function patchVertexPoints(points, vertexIndex, lat, lon) {
const src = Array.isArray(points) ? points : [];
return src.map((p, i) =>
i === vertexIndex ? { lat, lon } : { lat: p.lat, lon: p.lon },
);
}
```
Instance method:
```js
_liveSetSurfacePoints(surfaceIndex, points) {
const layer = this._layerByKey.get(`surface:${surfaceIndex}`);
applySurfaceLatLngs(layer, pointsToLatLngs(points));
}
```
#### Normative drag handlers (order-independent paint — **only** this path)
```js
handle.on('dragstart', (ev) => {
this._dragging = true;
this._dragEndedAt = 0;
if (ev?.originalEvent) this.L.DomEvent.stopPropagation(ev.originalEvent);
});
handle.on('drag', (ev) => {
const ll = ev.target.getLatLng();
// 1) Overlay-local paint FIRST (order-independent of model callback).
const base = this._airport?.surfaces?.[surfaceIndex]?.points || [];
const pts = patchVertexPoints(base, vi, ll.lat, ll.lng);
this._liveSetSurfacePoints(surfaceIndex, pts);
// 2) Model commit path (main must not refresh).
this.onVertexDrag(surfaceIndex, vi, ll.lat, ll.lng);
});
handle.on('dragend', (ev) => {
const ll = ev.target.getLatLng();
// Final live paint (covers last frame).
const base = this._airport?.surfaces?.[surfaceIndex]?.points || [];
const pts = patchVertexPoints(base, vi, ll.lat, ll.lng);
this._liveSetSurfacePoints(surfaceIndex, pts);
// State machine: clear pointer-down flag BEFORE app refresh path.
this._dragEndedAt = Date.now();
this._dragging = false;
this.onVertexDragEnd(surfaceIndex, vi, ll.lat, ll.lng);
});
```
**Rejected ordering (do not implement):** call `onVertexDrag` first then read `this._airport.surfaces[i].points` for paint. That works today only because `setVertex` mutates the shared graph in place (`model.js`), but it is order-dependent and breaks if the callback is ever async or non-mutating. Shared mutation remains true for the document model; paint must not rely on callback side effects.
**Ordering contract with `main.js`:**
| Event | OverlayController | main.js |
|-------|-------------------|---------|
| `drag` | `patchVertexPoints` → `_liveSetSurfacePoints` → `onVertexDrag` | `setVertex` only — **no** `refresh` |
| `dragend` | paint → `_dragEndedAt` + `_dragging = false` → `onVertexDragEnd` | `setVertex` + `afterAptMutation({ fit: false })` (full `refresh` **runs**) |
### 3. Map-click suppress after drag (single wire-up)
**Normative pure helper:**
```js
/**
* @param {{ dragging: boolean, dragEndedAt: number, suppressMs: number }} state
* @param {number} [now]
* @returns {boolean}
*/
export function shouldSuppressMapClick(state, now = Date.now()) {
if (state.dragging) return true;
if (
state.dragEndedAt > 0 &&
now - state.dragEndedAt < state.suppressMs
) {
return true;
}
return false;
}
export const MAP_CLICK_SUPPRESS_MS = 250;
```
**Normative controller fields** (constructor):
```js
this._dragging = false;
this._dragEndedAt = 0;
// MAP_CLICK_SUPPRESS_MS imported/exported constant
```
**Normative map click body** (replace todays `if (this._dragging) return`):
```js
if (this.onMapClick) {
map.on('click', (ev) => {
if (
shouldSuppressMapClick(
{
dragging: this._dragging,
dragEndedAt: this._dragEndedAt,
suppressMs: MAP_CLICK_SUPPRESS_MS,
},
Date.now(),
)
) {
return;
}
const ll = ev.latlng;
this.onMapClick(ll.lat, ll.lng, ev.originalEvent);
});
}
```
**Aircraft dragend** uses the same state fields and the same helper (set `_dragEndedAt`, clear `_dragging` **before** `onAircraftDragEnd`).
**Test matrix (required rows):**
| `dragging` | `dragEndedAt` relative to `now` | `shouldSuppressMapClick` | `render` allowed? |
|------------|----------------------------------|--------------------------|-------------------|
| `true` | any | `true` | **No** (caller invariant; pointer still down) |
| `false` | within `suppressMs` | `true` | **Yes** (post-drag rebuild must run) |
| `false` | after `suppressMs` / `0` | `false` | **Yes** |
This is the critical distinction fixed after review: **post-drag click suppress must not block `render`.**
### 4. CSS hit target
```css
/* Vertex drag handles (Leaflet divIcon).
* Width/height MUST match VERTEX_HANDLE_PX (map-layers.js) — keep in sync.
* Do not use negative margin; Leaflet positions via iconAnchor. */
.apted-vertex-handle {
position: relative; /* containing block for ::after hit pad */
overflow: visible; /* do not clip expanded hit area */
width: 16px !important;
height: 16px !important;
margin: 0 !important; /* positioning is iconAnchor only */
border: 2px solid #4a7ab0;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
cursor: grab;
box-sizing: border-box;
}
.apted-vertex-handle:active {
cursor: grabbing;
background: #e8f0fa;
}
/* Expanded hit area without growing the visible disc.
* Relies on position:relative + overflow:visible on the icon. */
.apted-vertex-handle::after {
content: '';
position: absolute;
inset: -6px; /* ~28px effective target */
}
```
Notes:
- Leaflet marker icons are typically `position: absolute` with explicit width/height; `position: relative` on the icon element establishes the containing block for `::after` without fighting map placement (the icons own transform is on the parent marker pane node).
- CSS `!important` width/height overrides Leaflets inline size styles from `iconSize` — **both** must stay at 16. Accept comment-synced constants; do **not** inject user-derived HTML via `divIcon` `html:` (XSS).
- Checklist item: verify centered grab in manual smoke step 2.
### 5. `main.js` callback simplification
```js
onVertexDrag(si, vi, lat, lon) {
setVertex(doc, si, vi, { lat, lon });
// Intentionally no refresh — OverlayController live-updates the surface layer.
// Hard invariant: never refresh/render while OverlayController._dragging.
},
onVertexDragEnd(si, vi, lat, lon) {
setVertex(doc, si, vi, { lat, lon });
afterAptMutation({ fit: false }); // full refresh — _dragging already false
},
```
Status tip (PR2):
- After successful surface select (map or rail) in Select mode, `showStatus('Drag white handles to move vertices. Click empty map to deselect.', false)`.
- Toolbar tip for Select mode remains: “Select mode — click features; drag vertices.”
- Do **not** thrash status on every drag tick; tip only when selection becomes a surface.
Do **not** call `rail.render` on every drag tick (would thrash DOM). Inspector lat/lon table updates on dragend via existing `refresh` — acceptable; live inspector coords are non-goal.
### 6. Mid-drag `render` policy (hard invariant — no soft early-out)
**Chosen approach (K9):** callers must not call `render` / `refresh` / `setSelection` while a vertex or aircraft drag is active (`_dragging === true`).
- `onVertexDrag` / `onAircraftDrag` already forbid refresh.
- Rail `onSelect` → `refresh` during an active map drag is not a supported path; if it ever happens, Leaflets active `Draggable` is already at risk — a soft early-out that only caches `_airport` would **drop `_selection` updates** and desync controller vs `doc` without fixing the Draggable issue.
- Therefore: **omit** a `if (this._dragging) return` early-out inside `render`. Keep `render` simple and always full-rebuild. Enforcement is the main.js / callback contract + state machine above.
- `setSelection` continues to call `render` as today; it is only used when the user is not mid-drag of a vertex handle.
### 7. Parking / single-point hold
Existing `_addSurface` already uses `circleMarker` for parking and single-point hold. `applySurfaceLatLngs` uses `setLatLng`. No second code path. Layer-type contract in §2 applies.
**Not in scope:** making the parking disc itself the only drag affordance (handle is enough once aligned).
### 8. Interaction simplification summary
| Before | After |
|--------|--------|
| Dual circle + marker | Single grab handle |
| 12px + offset margins | 16px + expanded `::after` hit pad, no margin offset |
| Line frozen mid-drag | Line/point follows handle |
| 50ms `_dragging` hold doubles as click suppress | `_dragging` pointer-only; `dragEndedAt` + pure helper for clicks |
| Dragend refresh blocked if suppress overloads `_dragging` | Dragend clears `_dragging` first; full rebuild always |
| Opaque failure modes | Status tip on select; selection retained after drag |
Modes remain: Select / Park / Taxi / Runway / Hold / Aircraft. No mode removed or added.
### Architecture after fix
```mermaid
flowchart TB
subgraph mid_drag ["During drag (_dragging=true; no full render)"]
H[Vertex L.marker]
H -->|drag| Patch[patchVertexPoints]
Patch --> Live["applySurfaceLatLngs"]
H -->|onVertexDrag| SV["setVertex(doc)"]
Live --> PL["polyline or circleMarker\n(surface:N contract)"]
SV --> Doc[(EditorDocument)]
end
subgraph end_drag ["On dragend"]
H2[dragend] --> Clear["_dragEndedAt=now\n_dragging=false"]
Clear --> EndCb[onVertexDragEnd]
EndCb --> SV2[setVertex]
SV2 --> AAM[afterAptMutation]
AAM --> Val[validateDocument]
AAM --> Ref["refresh → full render\n(allowed)"]
Ref --> Rail[rail + titlebar chips]
end
subgraph click ["Map click"]
C[map click] --> Sup{shouldSuppressMapClick}
Sup -->|dragging or within suppressMs| Ignore[ignore]
Sup -->|else| ClearSel[select mode: clear selection]
end
```
---
## API / Interface Changes
No server HTTP, Go package, or import-graph changes.
### JS exports (additive, pure)
| Export | Module | Purpose |
|--------|--------|---------|
| `pointsToLatLngs(points)` | `map-layers.js` | Finite point → `[lat,lon][]` |
| `applySurfaceLatLngs(layer, latlngs)` | `map-layers.js` | Duck-typed live geometry apply; returns branch taken |
| `patchVertexPoints(points, vi, lat, lon)` | `map-layers.js` | Immutable-style vertex patch for paint |
| `shouldSuppressMapClick(state, now?)` | `map-layers.js` | Drag / post-drag map-click filter |
| `MAP_CLICK_SUPPRESS_MS` | `map-layers.js` | Default 250 |
| `VERTEX_HANDLE_PX` | `map-layers.js` | 16; comment-synced with CSS |
| `buildVertexHandleOptions(vi)` | `map-layers.js` | Marker options for tests |
| `buildVertexHandleIconOptions()` | `map-layers.js` | `iconSize`/`iconAnchor` symmetry for tests |
### `OverlayController` internal methods
| Method | Visibility | Behavior |
|--------|------------|----------|
| `_liveSetSurfacePoints(si, points)` | private | `applySurfaceLatLngs` via `_layerByKey` |
| `_addVertexHandles` | private | Single marker; normative drag state machine |
| `render` | public | Full rebuild always; **no** mid-drag early-out |
### Callback contracts (unchanged signatures)
```ts
onVertexDrag?: (surfaceIndex: number, vertexIndex: number, lat: number, lon: number) => void
onVertexDragEnd?: (surfaceIndex: number, vertexIndex: number, lat: number, lon: number) => void
```
Semantics clarified:
- **`onVertexDrag`**: may fire at high frequency; **must not** trigger full overlay `render` / `refresh`.
- **`onVertexDragEnd`**: invoked only after OverlayController has set `_dragging = false`; **must** full-refresh via existing mutation path.
### CSS
- `.apted-vertex-handle` as §4 (`position: relative`, `overflow: visible`, no negative margin).
- No new global Leaflet overrides.
---
## Data Model Changes
**None.**
- `setVertex` / `insertVertex` / `deleteVertex` remain the mutation API.
- Dirty flags, download hash, validation arrays unchanged.
- No schema / migration / server storage.
---
## Alternatives Considered
### A1 — Vendor `leaflet-draw` / `leaflet-geoman`
| Pros | Cons |
|------|------|
| Mature vertex edit UX | New dependency weight; complexity-gate; harder pure-unit testing; diverges from parent “custom tools first” |
| | Larger review surface than a bugfix |
**Rejected** for this fix. Revisit only if select→drag still inadequate after the fix PR (parent already deferred this).
### A2 — Always show vertices for all surfaces in Select mode
| Pros | Cons |
|------|------|
| Matches “drag any node on any polyline” literally | Visual clutter (hundreds of handles); hit-testing ambiguity between adjacent taxiways; expensive layer count |
| | Still need selection for inspector/delete |
**Rejected** as default. Optional future: “show all vertices” toggle — out of scope.
### A3 — Custom mousedown/mousemove drag on `circleMarker` only (no `L.marker`)
| Pros | Cons |
|------|------|
| One layer type for polylines and handles | Reimplement Leaflet.Draggable; more bug surface; map pan conflicts |
**Rejected** — fix `divIcon` marker path instead.
### A4 — Live update only on dragend (model-only mid-drag)
| Pros | Cons |
|------|------|
| Minimal code | **Does not fix user report**; line still frozen |
**Rejected** — contradicts parent performance/UX note and user ask.
### A5 — Full rewrite of overlay as React/canvas
| Pros | Cons |
|------|------|
| — | Violates boring-web / house SPA ban; massive scope |
**Rejected.**
### A6 — Soft `render` early-out while `_dragging` (cache-only)
| Pros | Cons |
|------|------|
| Defensive if a buggy caller refreshes mid-drag | Easy to get wrong (drop `_selection`); masks caller bugs; blocked post-drag rebuild when incorrectly tied to suppress window (rev1 defect) |
**Rejected** in favor of hard caller invariant (K9).
### Chosen approach vs alternatives
**Targeted OverlayController + main + CSS fix in one user-complete PR** wins: smallest diff, aligns with parent design, testable pure helpers, no new deps, preserves draw/select modes, non-contradictory state machine.
---
## Security & Privacy Considerations
| Topic | Assessment |
|-------|------------|
| XSS | Continue **no `innerHTML`** for file-derived labels; `divIcon` uses `className` only (no `html:` with surface names). Tooltips stay `tooltipTextNode` / `textContent`. |
| Authz | Unchanged — Admin-only page shell; no new routes. |
| CSRF | No new state-changing server endpoints. |
| Data | Geometry still browser-local; drag does not POST coordinates. |
| Threat | Malicious `.apt` with huge point counts — already a client DoS concern; live `setLatLngs` is O(points per surface) per move event, fine for budgeted sizes. |
No new privacy surface.
---
## Observability
Client-only feature; no new server metrics.
| Mechanism | Use |
|-----------|-----|
| Existing status flash (`showStatus`) | User feedback on select / errors |
| `doc.aptDirty` + titlebar `APT*` | Confirms drag committed |
| Validate tab | Catches invalid geometry after edit |
| `slog` / Gin | Unchanged (page load only) |
Do **not** add `console.log` noise (hygiene / house style). No browser RUM required.
---
## Testing Strategy
### Pure Node tests (`webjs/airport-editor/map-layers.test.js`)
Add cases for:
1. **`pointsToLatLngs`**: empty, skips non-finite, preserves order.
2. **`applySurfaceLatLngs`** with mock layers:
- `{ setLatLngs(calls) }` → branch `'polyline'`, called with full latlngs.
- `{ setLatLng(calls) }` only → branch `'point'`, called with first latlng.
- `{}` / `null` → `'none'`.
3. **`patchVertexPoints`**: replaces only index `vi`; does not mutate input array.
4. **`shouldSuppressMapClick`** (full matrix from §3):
- `dragging: true` → true (render still “allowed” only in the sense of policy — document that post-drag uses `dragging: false`).
- `dragging: false`, within `suppressMs` after `dragEndedAt` → true (**and** render must still be allowed — separate concern; assert suppress only).
- `dragging: false`, after window / `dragEndedAt: 0` → false.
5. **`buildVertexHandleIconOptions`**: `iconSize[0] === iconSize[1] === VERTEX_HANDLE_PX`; `iconAnchor` is half; className includes `apted-vertex-handle`.
6. **`buildVertexHandleOptions`**: `draggable: true`, `autoPan: false`, `bubblingMouseEvents: false`.
7. Short comment block above the suite: RC1 (live path = `applySurfaceLatLngs`), RC2 (no companion circle factory in exports), RC3 (no CSS margin in JS positioning), K5 (suppress ≠ block render).
Existing `setVertex` tests in `model.test.js` remain sufficient for model commits.
**Not required:** parsing `map-layers.js` source text for “circleMarker” string matches (brittle). Prefer exported pure builders so “no dual handle” is a construction discipline + code review of `_addVertexHandles`.
### What we will not automate (default)
- Real Leaflet pointer drag in headless Chrome (Playwright).
- Visual pixel diffs.
### Manual smoke checklist (PR description / engineer runbook)
Run `go run ./cmd/openfsd -web` (or full binary), Admin login, open `/airport-editor`:
1. Open a multi-surface `.apt` (e.g. KBTV-scale fixture if available) → Fit.
2. **Select mode** → click taxiway → handles appear **centered** on vertices (single white disc each — no double-dot).
3. Drag a mid vertex → **polyline follows** handle continuously.
4. Release → line stays; selection **remains**; titlebar shows `APT*`; inspector coords updated (full refresh ran).
5. Drag runway end vertex → same.
6. Select parking → drag its single handle → disc follows; release keeps selection.
7. Select hold polyline / point → same.
8. Click empty map (after >~300ms) → selection clears (handles gone). Immediately after a drag, a spurious map click should **not** clear selection.
9. Drag aircraft marker → still works; no vertex regression.
10. Draw new taxi (mode Taxi) → finish → select → drag still works.
11. Rail: Add vertex / Delete vertex → map updates; drag new vertex works. (**Note:** keyboard Del still deletes **surface**, not vertex — expected; see Known gaps.)
12. Download `.apt` → coords reflect dragged positions (spot-check raw / re-open).
13. Repeat a drag near map edge with `autoPan: false` — map should not jump unexpectedly.
14. JS disabled: page shell still loads (map PE exception); no server regression.
### CI
```bash
bash scripts/check-webjs.sh
# or: cd webjs && npm test
go test -race ./internal/web/... # no Go change expected; sanity
bash scripts/check-hygiene.sh # if any Go touched (should not be)
```
---
## Rollout Plan
| Stage | Action |
|-------|--------|
| Implement | **One user-complete fix PR** (see PR Plan); optional follow-up for status copy/docs; **no feature flag** |
| Verify | Manual smoke on local binary + `check-webjs.sh` green |
| Deploy | Standard openfsd release; static assets go with binary (`go:embed` / static tree as today) |
| Rollback | Revert PR(s); no migration. Users may have downloaded interim APT files — no server state. |
| Flag | Not needed; risk contained to Admin map editor |
### Risks
| Risk | Severity | Mitigation |
|------|----------|------------|
| Residual click still clears selection on some browsers | Med | `dragEndedAt` + 250ms suppress + `bubblingMouseEvents: false` + stopPropagation; tune `MAP_CLICK_SUPPRESS_MS` if smoke fails (see OQ1) |
| Live update no-ops if surface layer wrapped later | Med | Layer-type contract in §2; `applySurfaceLatLngs` returns `'none'`; code review of `_addSurface` |
| Caller `refresh` mid-drag destroys Draggable | Low | Hard invariant; no soft early-out; keep drag callbacks refresh-free |
| CSS/`VERTEX_HANDLE_PX` drift | Low | Comment keep-in-sync; icon options unit test; smoke step 2 |
| Operator habit: expects drag without select | Low | Status tip; optional future “show all vertices” non-goal |
| QA expects parent “Del = delete vertex” | Low | Document known gap (rail-only vertex delete) in PR / design note |
---
## Open Questions
1. **Suppress duration**: Is 250ms enough on slow trackpads / touch, or should it be 400ms (near old mobile 300ms synthetic click)? Resolve via manual smoke; constant is `MAP_CLICK_SUPPRESS_MS`.
2. **Live inspector coordinates**: Update rail vertex table mid-drag? **Default no** (DOM thrash). Confirm with product if instructors edit numbers while dragging (unlikely).
3. **Delete key vs parent table**: Parent UX table says “Delete vertex | Select handle + Delete key”; implementation deletes **surface**. This fix **accepts rail-only vertex delete** and documents the gap. Vertex-level Del is a separate small UX PR if desired.
4. **Copy design into repo**: **Done** — this file (`docs/design/airport-editor-vertex-drag.md`).
### Known gaps (document, do not fix here)
- **Vertex delete is rail-only** (`ui-rail.js` delete-vertex action). Keyboard Delete/Backspace with a surface selected deletes the **entire surface** (`main.js` `onKeyDown`). Parent design table claim is aspirational / stale relative to shipped code.
---
## References
- Parent design: `docs/design/apt-air-editor.md` (Implemented) — vertex UX table, performance note, PR7 edit loop.
- Agents / package rules: `Agents.md` — `internal/web` isolation; boring-web mandatory; coverage + `check-webjs.sh`.
- Boring-web: `~/.grok/skills/boring-web/SKILL.md`, checklist, decision-test (map PE exception already documented for this page).
- Implementation files:
- `internal/web/static/js/openfsd/airport-editor/map-layers.js` — `OverlayController`, `_addVertexHandles`
- `internal/web/static/js/openfsd/airport-editor/main.js` — `onVertexDrag` / `onVertexDragEnd` / `refresh`
- `internal/web/static/js/openfsd/airport-editor/model.js` — `setVertex`
- `internal/web/static/css/openfsd/airport-editor.css` — `.apted-vertex-handle`
- `webjs/airport-editor/map-layers.test.js`, `model.test.js`
- Leaflet CSS: `internal/web/static/css/leaflet.css` (marker pane z-index, `.leaflet-interactive` pointer-events, `.leaflet-div-icon`)
---
## PR Plan
Prefer a **tight, user-complete fix** over a three-way split. CSS grab alignment (RC3, high severity) must not land after “live geometry only.”
### PR 1 — Fix vertex drag (user-complete) **[default]**
| Field | Content |
|-------|---------|
| **Title** | airport-editor: fix vertex drag (live geometry, handles, click suppress) |
| **Files** | `internal/web/static/js/openfsd/airport-editor/map-layers.js`; `internal/web/static/js/openfsd/airport-editor/main.js`; `internal/web/static/css/openfsd/airport-editor.css`; `webjs/airport-editor/map-layers.test.js` |
| **Depends on** | None |
| **Description** | **Single mergeable fix.** Remove dual circleMarker+marker. Order-independent live paint via `patchVertexPoints` + `applySurfaceLatLngs`. Normative state machine: clear `_dragging` before `onVertexDragEnd`; map click uses `shouldSuppressMapClick` with `_dragEndedAt`. CSS: remove double-offset margins, 16px handle, `position: relative` + `overflow: visible` + `::after` hit pad. Export pure helpers + handle option builders; unit tests for points, apply, suppress matrix, iconSize/iconAnchor symmetry. Same suppress for aircraft dragend. **Do not claim “user-complete” without CSS.** |
### PR 2 — UX copy + docs
| Field | Content |
|-------|---------|
| **Title** | airport-editor: select-mode drag status tip and design note |
| **Files** | `main.js` (status on surface select / mode tip alignment); `docs/design/airport-editor-vertex-drag.md` (this design); one-liner under parent “Implementation status” |
| **Depends on** | PR 1 |
| **Description** | On surface select in Select mode, flash a one-line tip to drag handles. Note parent Del-key / rail-only vertex delete gap. No Playwright. |
### Suggested merge order
```text
PR1 (user-complete fix) → PR2 (copy / docs) [both landed]
```
**Do not** split live geometry from CSS/suppress into separate merges by default — that leaves high-severity RC3 unfixed after “live geometry only.” If a multi-PR split is forced for review bandwidth, the **only** acceptable partial first land is CSS-only (margin fix) as a PR0 that can merge alone; never ship live geometry without CSS alignment as the advertised “fix.”
### Out-of-plan follow-ups (not blocking)
- Mid-edge insert handles on map.
- Optional “show all vertices” density toggle.
- Vertex-level Delete key (align with parent table).
- Quantize float chatter on format (parent already optional).
---
## Implementation checklist (engineer)
- [x] Single `divIcon` handle; no dual circleMarker
- [x] Live paint: `patchVertexPoints` → `applySurfaceLatLngs` on drag (**before** model callback)
- [x] Surface layer contract: `surface:N` is polyline or circleMarker only
- [x] `onVertexDrag` never calls `refresh` / `rail.render`
- [x] **dragend:** set `_dragEndedAt`, set `_dragging = false`, **then** `onVertexDragEnd` → `afterAptMutation({ fit: false })`
- [x] Map click uses `shouldSuppressMapClick` only (not a long-lived `_dragging` after pointer-up)
- [x] No soft `render` early-out that can block post-drag rebuild
- [x] CSS: no negative margin; `VERTEX_HANDLE_PX` matches; `position: relative`; `overflow: visible`
- [x] Pure tests: apply mock layers, suppress matrix (incl. “dragging false + within suppress → click suppressed, render still OK by contract”), icon symmetry
- [x] Selection retained after successful drag (smoke)
- [x] Document: vertex delete rail-only; Del deletes surface
- [x] Select-mode surface tip: `Drag white handles…` (PR2)
- [x] `bash scripts/check-webjs.sh` green
- [ ] Manual smoke § Testing complete (engineer runbook)
- [x] No new imports from `internal/web` → forbidden packages
- [x] No Playwright added
---
*End of design document.*

View File

@@ -24,6 +24,7 @@ Shipped. Summary of the tree as of closeout:
| Web JS | Pure modules under `internal/web/static/js/openfsd/`; Node tests in `webjs/` + `scripts/check-webjs.sh` in CI |
| Persistence | **None** durable (no disk/DB); Blob download + transient request bodies only |
| Playwright | **Cancelled** — no browser automation suite |
| Vertex drag | **Fixed** (live geometry + handles); see `docs/design/airport-editor-vertex-drag.md`. Vertex delete remains rail-only (Del/Backspace deletes the whole surface). |
Design history below is retained. Stale “Format missing / JS tests none” rows are updated in Background.

View File

@@ -111,6 +111,7 @@ function main() {
if (doc.mode !== MODE_SELECT) return;
doc.selection = normalizeSelection(sel);
refresh();
showSurfaceSelectTip();
},
onVertexDrag(si, vi, lat, lon) {
setVertex(doc, si, vi, { lat, lon });
@@ -187,6 +188,7 @@ function main() {
overlays.clearDrawPreview();
}
refresh();
showSurfaceSelectTip();
},
onAirportPatch(patch) {
updateAirportHeaders(doc, patch);
@@ -758,6 +760,19 @@ function main() {
el.setAttribute('role', isError ? 'alert' : 'status');
}
/**
* One-line tip when a surface is selected in Select mode (map or rail).
* Only on selection change — never mid-drag.
*/
function showSurfaceSelectTip() {
if (doc.mode !== MODE_SELECT) return;
if (doc.selection?.type !== 'surface') return;
showStatus(
'Drag white handles to move vertices. Click empty map to deselect.',
false,
);
}
// Silence unused kind imports for tree-shaking edge cases
void SurfaceParking;
void SurfaceHold;