airport-editor: always-on small vertices, grab without pre-select

Show 10px centered nodes on all surfaces in Select mode. Capture hit-test
finds the nearest vertex on any surface (no click-to-select first) and
auto-selects that surface. Fix knob offset via border-box + override of
leaflet-div-icon defaults; do not snap vertex to cursor on mousedown.
This commit is contained in:
Reese Norris
2026-07-27 15:33:27 -04:00
parent e8c6965fe0
commit e33dab35eb
5 changed files with 164 additions and 63 deletions

View File

@@ -267,25 +267,33 @@
pointer-events: none !important; /* visuals only */
}
/* Vertex drag handles (Leaflet divIcon) — visual affordance only.
* Width/height MUST match VERTEX_HANDLE_PX (map-layers.js).
* Do NOT set position: relative — Leaflet needs absolute for lat/lng placement. */
.apted-vertex-handle {
/* Vertex nodes (Leaflet divIcon) — must stay centered on lat/lng.
* Width/height MUST match VERTEX_HANDLE_PX (map-layers.js) = 10.
* Override .leaflet-div-icon defaults (1px border / background) that offset the disc.
* box-sizing:border-box so border is inside iconSize and iconAnchor stays true center.
* Do NOT set position: relative — Leaflet needs absolute for placement. */
.leaflet-div-icon.apted-vertex-handle {
overflow: visible;
width: 18px !important;
height: 18px !important;
width: 10px !important;
height: 10px !important;
margin: 0 !important;
border: 2px solid #4a7ab0;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 1px 4px rgba(0, 0, 0, 0.25);
padding: 0 !important;
border: 1.5px solid #4a7ab0 !important;
border-radius: 50% !important;
background: #fff !important;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3);
cursor: grab;
box-sizing: border-box;
box-sizing: border-box !important;
}
.apted-vertex-handle.is-dragging {
.leaflet-div-icon.apted-vertex-handle.is-selected {
border-color: #2a5a90 !important;
background: #e8f0fa !important;
box-shadow: 0 0 0 1px rgba(42, 90, 144, 0.45);
}
.leaflet-div-icon.apted-vertex-handle.is-dragging {
cursor: grabbing;
background: #e8f0fa;
border-color: #2a5a90;
border-color: #1a3a60 !important;
background: #d0e4f8 !important;
}
/* Inspector form density */

View File

@@ -430,7 +430,7 @@ function main() {
}
refresh();
const tips = {
[MODE_SELECT]: 'Select mode — click features; drag vertices.',
[MODE_SELECT]: 'Select mode — drag any white vertex; click a line to select.',
[MODE_PARK]: 'Park mode — click map to place parking.',
[MODE_TAXI]: 'Taxi mode — click vertices; Enter/double-click finish.',
[MODE_RUNWAY]: 'Runway mode — click ≥2 points; Enter/double-click finish.',
@@ -733,6 +733,8 @@ function main() {
* @param {{ fit?: boolean }} [opts]
*/
function refresh(opts = {}) {
// Vertex nodes + grab only in Select mode (draw modes keep full map free).
overlays.setVertexEditActive(doc.mode === MODE_SELECT);
overlays.render(doc.airport, doc.aircraft, doc.selection);
if (draw.active && draw.points.length) {
overlays.setDrawPreview(draw.points, draw.kind || SurfaceTaxiway);
@@ -763,7 +765,7 @@ function main() {
/** Status banner for surface select in Select mode (not mid-drag). */
const SURFACE_SELECT_TIP =
'Selected — drag the white dots to move vertices. Click empty map to deselect.';
'Drag any white vertex to reshape. Click empty map to deselect.';
/**
* Show tip when selection is a surface; clear it when selection leaves a

View File

@@ -203,13 +203,13 @@ export function escapeHtml(s) {
}
/** Keep in sync with .apted-vertex-handle width/height in airport-editor.css */
export const VERTEX_HANDLE_PX = 18;
export const VERTEX_HANDLE_PX = 10;
/**
* Pixel radius for vertex grab hit-test (capture-phase, independent of icon DOM hits).
* Slightly larger than the visible handle so corners are easy to grab.
* Larger than the visual handle so small nodes stay easy to grab.
*/
export const VERTEX_HIT_PX = 20;
export const VERTEX_HIT_PX = 14;
/** Duration after dragend during which map clicks are ignored. */
export const MAP_CLICK_SUPPRESS_MS = 250;
@@ -297,6 +297,36 @@ export function findNearestVertexPx(verticesPx, clickPx, maxDistPx) {
return { index: bestIdx, dist: bestDist };
}
/**
* Pure: nearest vertex across many surfaces (for grab-without-select).
*
* @param {{ surfaceIndex: number, points: { x: number, y: number }[] }[]} surfacesPx
* @param {{ x: number, y: number }} clickPx
* @param {number} maxDistPx
* @returns {{ surfaceIndex: number, vertexIndex: number, dist: number }|null}
*/
export function findNearestVertexAcrossSurfaces(surfacesPx, clickPx, maxDistPx) {
if (!Array.isArray(surfacesPx) || !clickPx) return null;
const max = Number(maxDistPx);
if (!Number.isFinite(max) || max < 0) return null;
/** @type {{ surfaceIndex: number, vertexIndex: number, dist: number }|null} */
let best = null;
for (const s of surfacesPx) {
if (!s || !Array.isArray(s.points)) continue;
const hit = findNearestVertexPx(s.points, clickPx, max);
if (!hit) continue;
if (!best || hit.dist < best.dist) {
best = {
surfaceIndex: s.surfaceIndex,
vertexIndex: hit.index,
dist: hit.dist,
};
}
}
return best;
}
/**
* Pure: points → Leaflet latlng tuples (finite only).
* @param {{lat:number,lon:number}[]} points
@@ -598,6 +628,8 @@ export class OverlayController {
this.onMapClick = opts.onMapClick || null;
this.onMapDblClick = opts.onMapDblClick || null;
this.editable = opts.editable !== false;
/** When false (e.g. draw modes), hide nodes and ignore vertex grab. */
this.vertexEditActive = opts.vertexEditActive !== false;
this.planeIconUrl = opts.planeIconUrl || '/static/images/plane.png';
this.group = L.featureGroup().addTo(map);
this.vertexGroup = L.featureGroup().addTo(map);
@@ -688,6 +720,8 @@ export class OverlayController {
if (this._vertexDrag) {
this._airport = airport;
this._aircraft = Array.isArray(aircraft) ? aircraft : [];
// Keep selection in sync for rail without recreating layers.
this._selection = normalizeSelection(selection);
return;
}
@@ -707,13 +741,22 @@ export class OverlayController {
this._addAircraft(this._aircraft[i], i);
}
// Vertex handles for selected surface (visual markers; grab is capture hit-test).
if (this.editable && this._selection?.type === 'surface') {
const s = surfaces[this._selection.index];
if (s) this._addVertexHandles(s, this._selection.index);
// Small vertex nodes on every surface — grab via capture hit-test (no pre-select).
if (this.editable && this.vertexEditActive) {
for (let i = 0; i < surfaces.length; i++) {
this._addVertexHandles(surfaces[i], i);
}
}
}
/**
* Enable/disable always-on vertex nodes + grab (typically Select mode only).
* @param {boolean} active
*/
setVertexEditActive(active) {
this.vertexEditActive = !!active;
}
/**
* Draw preview polyline for in-progress draw session.
* @param {{ lat: number, lon: number }[]} points
@@ -918,57 +961,67 @@ export class OverlayController {
}
/**
* Hit-test selected surface vertices in container pixels.
* Hit-test every surface vertex in container pixels (no pre-select required).
* @param {MouseEvent|TouchEvent} domEv
* @returns {{ surfaceIndex: number, vertexIndex: number, handle: *|null }|null}
*/
_hitTestSelectedVertex(domEv) {
if (!this.editable) return null;
if (this._selection?.type !== 'surface') return null;
const si = this._selection.index;
const surface = this._airport?.surfaces?.[si];
if (!surface || !Array.isArray(surface.points)) return null;
_hitTestAnyVertex(domEv) {
if (!this.editable || !this.vertexEditActive) return null;
const airport = this._airport;
if (!airport || !Array.isArray(airport.surfaces) || airport.surfaces.length === 0) {
return null;
}
const clickPx = this._containerPointFromPointerEvent(domEv);
if (!clickPx) return null;
const map = this.map;
/** @type {{ x: number, y: number }[]} */
const verticesPx = [];
for (const p of surface.points) {
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) {
verticesPx.push({ x: NaN, y: NaN });
continue;
}
try {
const cp = map.latLngToContainerPoint([p.lat, p.lon]);
verticesPx.push({ x: cp.x, y: cp.y });
} catch {
verticesPx.push({ x: NaN, y: NaN });
/** @type {{ surfaceIndex: number, points: { x: number, y: number }[] }[]} */
const surfacesPx = [];
for (let si = 0; si < airport.surfaces.length; si++) {
const surface = airport.surfaces[si];
const pts = surface?.points;
if (!Array.isArray(pts) || pts.length === 0) continue;
/** @type {{ x: number, y: number }[]} */
const verticesPx = [];
for (const p of pts) {
if (!Number.isFinite(p?.lat) || !Number.isFinite(p?.lon)) {
verticesPx.push({ x: NaN, y: NaN });
continue;
}
try {
const cp = map.latLngToContainerPoint([p.lat, p.lon]);
verticesPx.push({ x: cp.x, y: cp.y });
} catch {
verticesPx.push({ x: NaN, y: NaN });
}
}
surfacesPx.push({ surfaceIndex: si, points: verticesPx });
}
const hit = findNearestVertexPx(verticesPx, clickPx, VERTEX_HIT_PX);
const hit = findNearestVertexAcrossSurfaces(surfacesPx, clickPx, VERTEX_HIT_PX);
if (!hit) return null;
const key = `${si}:${hit.index}`;
const key = `${hit.surfaceIndex}:${hit.vertexIndex}`;
return {
surfaceIndex: si,
vertexIndex: hit.index,
surfaceIndex: hit.surfaceIndex,
vertexIndex: hit.vertexIndex,
handle: this._handleByKey.get(key) || null,
};
}
/**
* Capture-phase mousedown/touchstart on map container.
* If near a selected-surface vertex: stop map pan and start vertex drag.
* If near any surface vertex: stop map pan and start vertex drag (auto-selects surface).
* @param {MouseEvent|TouchEvent} ev
*/
_handlePointerDownCapture(ev) {
if (!this.editable || this._vertexDrag || this._dragging) return;
if (!this.editable || !this.vertexEditActive || this._vertexDrag || this._dragging) {
return;
}
// Ignore non-primary mouse buttons.
if ('button' in ev && ev.button !== 0 && ev.type === 'mousedown') return;
const hit = this._hitTestSelectedVertex(ev);
const hit = this._hitTestAnyVertex(ev);
if (!hit) return;
// Critical: prevent Leaflet Map.Drag (listens on container, bubble phase)
@@ -977,7 +1030,12 @@ export class OverlayController {
if (typeof ev.stopImmediatePropagation === 'function') ev.stopImmediatePropagation();
else if (typeof ev.stopPropagation === 'function') ev.stopPropagation();
// Begin drag first so onSelect → refresh sees _vertexDrag and skips rebuild.
this._beginVertexDrag(hit.surfaceIndex, hit.vertexIndex, hit.handle, ev);
// Select the surface so rail/highlight follow (no prior click required).
this._featureClickAt = Date.now();
this.onSelect({ type: 'surface', index: hit.surfaceIndex });
}
/**
@@ -1023,11 +1081,9 @@ export class OverlayController {
/* ignore */
}
// Seed first position from the pointer if available.
const seed = this._latLngFromPointerEvent(/** @type {MouseEvent|TouchEvent} */ (domEv));
if (seed) {
this._applyVertexDrag(surfaceIndex, vertexIndex, seed.lat, seed.lng, handle);
}
// Do not snap vertex to cursor on mousedown — that made the knob feel off-center.
// Only update geometry once the pointer actually moves.
void domEv;
const onMove = (ev) => {
if (!this._vertexDrag) return;
@@ -1090,8 +1146,8 @@ export class OverlayController {
}
/**
* Vertex handle *visuals* for selected surface (non-interactive markers).
* Grab is owned by capture-phase hit-test on the map container.
* Small vertex handle *visuals* for a surface (non-interactive markers).
* Grab is owned by capture-phase hit-test on the map container (any surface).
* @param {import('./model.js').Surface} surface
* @param {number} surfaceIndex
*/
@@ -1100,12 +1156,19 @@ export class OverlayController {
const pts = surface.points || [];
ensureVertexPane(this.map);
const selected =
this._selection?.type === 'surface' && this._selection.index === surfaceIndex;
const iconBase = buildVertexHandleIconOptions();
for (let vi = 0; vi < pts.length; vi++) {
const p = pts[vi];
if (!Number.isFinite(p.lat) || !Number.isFinite(p.lon)) continue;
const handle = L.marker([p.lat, p.lon], {
...buildVertexHandleOptions(vi),
icon: L.divIcon(buildVertexHandleIconOptions()),
icon: L.divIcon({
...iconBase,
className: iconBase.className + (selected ? ' is-selected' : ''),
}),
});
handle.addTo(this.vertexGroup);
this._handleByKey.set(`${surfaceIndex}:${vi}`, handle);

View File

@@ -212,7 +212,7 @@
Static assets are go:embed into the openfsd binary — restart the process after
JS/CSS changes. Query string busts browser cache when the binary is rebuilt.
*/}}
<script src="/static/js/leaflet.js?v=vertex-edit-3"></script>
<script src="/static/js/openfsd/leaflet.rotatedmarker.js?v=vertex-edit-3"></script>
<script type="module" src="/static/js/openfsd/airport-editor/main.js?v=vertex-edit-3"></script>
<script src="/static/js/leaflet.js?v=vertex-edit-4"></script>
<script src="/static/js/openfsd/leaflet.rotatedmarker.js?v=vertex-edit-4"></script>
<script type="module" src="/static/js/openfsd/airport-editor/main.js?v=vertex-edit-4"></script>
{{ end }}

View File

@@ -30,6 +30,7 @@ import {
buildVertexHandleOptions,
buildVertexHandleIconOptions,
findNearestVertexPx,
findNearestVertexAcrossSurfaces,
shouldSuppressMapClick,
stopLeafletClickBubble,
pointsToLatLngs,
@@ -358,13 +359,15 @@ test('stopLeafletClickBubble sets originalEvent._stopped (Leaflet map bubble gua
stopLeafletClickBubble({}, null);
});
test('buildVertexHandleIconOptions: iconSize/iconAnchor symmetry', () => {
test('buildVertexHandleIconOptions: iconSize/iconAnchor symmetry (centered disc)', () => {
const icon = buildVertexHandleIconOptions();
assert.equal(VERTEX_HANDLE_PX, 18);
assert.equal(VERTEX_HANDLE_PX, 10);
assert.equal(icon.iconSize[0], VERTEX_HANDLE_PX);
assert.equal(icon.iconSize[1], VERTEX_HANDLE_PX);
// Anchor must be exact half so the disc centers on the lat/lng.
assert.equal(icon.iconAnchor[0], VERTEX_HANDLE_PX / 2);
assert.equal(icon.iconAnchor[1], VERTEX_HANDLE_PX / 2);
assert.equal(icon.iconAnchor[0] * 2, icon.iconSize[0]);
assert.match(icon.className, /apted-vertex-handle/);
assert.match(icon.className, /leaflet-div-icon/);
});
@@ -383,8 +386,33 @@ test('buildVertexHandleOptions: visual-only (capture-phase owns drag)', () => {
assert.match(opts.title, /Vertex 3/);
});
test('findNearestVertexAcrossSurfaces: grab without pre-select', () => {
const surfaces = [
{
surfaceIndex: 0,
points: [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
],
},
{
surfaceIndex: 2,
points: [
{ x: 50, y: 50 },
{ x: 200, y: 200 },
],
},
];
const hit = findNearestVertexAcrossSurfaces(surfaces, { x: 52, y: 48 }, 14);
assert.ok(hit);
assert.equal(hit.surfaceIndex, 2);
assert.equal(hit.vertexIndex, 0);
assert.equal(findNearestVertexAcrossSurfaces(surfaces, { x: 900, y: 900 }, 14), null);
assert.equal(findNearestVertexAcrossSurfaces(null, { x: 0, y: 0 }, 14), null);
});
test('findNearestVertexPx: hit-test matrix (RCA capture-phase grab)', () => {
assert.ok(VERTEX_HIT_PX >= 16);
assert.ok(VERTEX_HIT_PX >= 10);
const verts = [
{ x: 100, y: 100 },
{ x: 200, y: 100 },