diff --git a/internal/web/static/js/openfsd/airport-editor/main.js b/internal/web/static/js/openfsd/airport-editor/main.js
index 290392d..1ffba1a 100644
--- a/internal/web/static/js/openfsd/airport-editor/main.js
+++ b/internal/web/static/js/openfsd/airport-editor/main.js
@@ -763,7 +763,7 @@ function main() {
/** Status banner for surface select in Select mode (not mid-drag). */
const SURFACE_SELECT_TIP =
- 'Drag white handles to move vertices. Click empty map to deselect.';
+ 'Selected — drag the white dots to move vertices. Click empty map to deselect.';
/**
* Show tip when selection is a surface; clear it when selection leaves a
diff --git a/internal/web/static/js/openfsd/airport-editor/map-layers.js b/internal/web/static/js/openfsd/airport-editor/map-layers.js
index 29a5ca3..8e20df8 100644
--- a/internal/web/static/js/openfsd/airport-editor/map-layers.js
+++ b/internal/web/static/js/openfsd/airport-editor/map-layers.js
@@ -209,11 +209,18 @@ export const VERTEX_HANDLE_PX = 18;
* 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.
*/
-export const VERTEX_HIT_PX = 16;
+export const VERTEX_HIT_PX = 20;
/** Duration after dragend during which map clicks are ignored. */
export const MAP_CLICK_SUPPRESS_MS = 250;
+/**
+ * After a feature (surface/aircraft) click, ignore map clicks for this long.
+ * Leaflet re-fires the same DOM click onto the map unless originalEvent._stopped
+ * is set; we do both _stopped and this time gate.
+ */
+export const FEATURE_CLICK_SUPPRESS_MS = 100;
+
/**
* Leaflet pane name for vertex handle visuals.
* createPane("aptedVertex") → CSS class "leaflet-aptedVertex-pane"
@@ -356,9 +363,41 @@ export function shouldSuppressMapClick(state, now = Date.now()) {
) {
return true;
}
+ if (
+ state.featureClickAt > 0 &&
+ now - state.featureClickAt < (state.featureSuppressMs ?? FEATURE_CLICK_SUPPRESS_MS)
+ ) {
+ return true;
+ }
return false;
}
+/**
+ * Stop a Leaflet layer mouse event from also firing on the map.
+ *
+ * Leaflet walks targets and continues while !originalEvent._stopped. Calling
+ * native stopPropagation() alone does NOT set _stopped on modern browsers, so
+ * map click still runs (and was clearing selection immediately after select).
+ *
+ * @param {*} ev Leaflet event with optional originalEvent
+ * @param {typeof globalThis.L} [L]
+ */
+export function stopLeafletClickBubble(ev, L) {
+ if (!ev) return;
+ const dom = ev.originalEvent;
+ if (dom) {
+ // Leaflet propagation flag (required — see _fireDOMEvent in leaflet.js).
+ dom._stopped = true;
+ if (L && L.DomEvent) {
+ L.DomEvent.stopPropagation(dom);
+ L.DomEvent.preventDefault(dom);
+ } else {
+ if (typeof dom.stopPropagation === 'function') dom.stopPropagation();
+ if (typeof dom.preventDefault === 'function') dom.preventDefault();
+ }
+ }
+}
+
/**
* Build a Leaflet tooltip content node with textContent only (never innerHTML).
* Leaflet 1.9 uses innerHTML for string content — always pass an Element.
@@ -447,12 +486,18 @@ export function createMap(L, mapEl, opts = {}) {
const zoom = opts.zoom ?? 2;
const map = L.map(mapEl, {
// Keep default attributionControl: true
+ // Box-zoom (shift-drag blue rectangle) confuses editors; pan/zoom still work.
+ boxZoom: false,
}).setView(center, zoom);
// Cosmetic only — must not remove attribution control or empty tile credits.
if (map.attributionControl && typeof map.attributionControl.setPrefix === 'function') {
map.attributionControl.setPrefix('');
}
+ // Belt-and-suspenders if boxZoom was already constructed.
+ if (map.boxZoom && typeof map.boxZoom.disable === 'function') {
+ map.boxZoom.disable();
+ }
const baseLayers = createBaseLayers(L, { blank: !!opts.blankTiles });
let activeBase = baseLayers.defaultKey;
@@ -569,6 +614,8 @@ export class OverlayController {
this._dragging = false;
/** @type {number} ms timestamp of last dragend (0 = never / reset on dragstart) */
this._dragEndedAt = 0;
+ /** @type {number} ms timestamp of last feature click (blocks map deselect) */
+ this._featureClickAt = 0;
/**
* Active manual vertex drag.
* @type {{ surfaceIndex: number, vertexIndex: number, handle: *|null, mapDraggingWasEnabled: boolean }|null}
@@ -611,6 +658,8 @@ export class OverlayController {
dragging: this._dragging,
dragEndedAt: this._dragEndedAt,
suppressMs: MAP_CLICK_SUPPRESS_MS,
+ featureClickAt: this._featureClickAt,
+ featureSuppressMs: FEATURE_CLICK_SUPPRESS_MS,
},
Date.now(),
)
@@ -750,6 +799,11 @@ export class OverlayController {
const key = `surface:${index}`;
let layer;
+ // bubblingMouseEvents:false — do not re-fire click on the map (would clear selection).
+ const pathOpts = {
+ bubblingMouseEvents: false,
+ };
+
if (surface.kind === SurfaceParking || (surface.kind === SurfaceHold && pts.length === 1)) {
const p = pts[0];
layer = L.circleMarker([p.lat, p.lon], {
@@ -759,6 +813,7 @@ export class OverlayController {
opacity: style.opacity,
fillColor: style.fillColor || style.color,
fillOpacity: style.fillOpacity ?? 0.85,
+ ...pathOpts,
});
} else {
// Multi-point surfaces (runway/taxi/hold polyline). Hold dash is in style.
@@ -768,12 +823,14 @@ export class OverlayController {
weight: style.weight,
opacity: style.opacity,
dashArray: style.dashArray,
+ ...pathOpts,
});
}
bindTextTooltip(layer, surfaceLabel(surface));
layer.on('click', (ev) => {
- if (ev.originalEvent) L.DomEvent.stopPropagation(ev.originalEvent);
+ stopLeafletClickBubble(ev, L);
+ this._featureClickAt = Date.now();
this.onSelect({ type: 'surface', index });
});
layer.addTo(this.group);
@@ -1079,7 +1136,8 @@ export class OverlayController {
const tip = `${ac.callsign || '?'} · ${ac.type || ''} · hdg ${hdg}`;
bindTextTooltip(marker, tip);
marker.on('click', (ev) => {
- if (ev.originalEvent) L.DomEvent.stopPropagation(ev.originalEvent);
+ stopLeafletClickBubble(ev, L);
+ this._featureClickAt = Date.now();
this.onSelect({ type: 'aircraft', index });
});
if (this.editable) {
diff --git a/internal/web/templates/airport_editor.html b/internal/web/templates/airport_editor.html
index 2fdcc96..7f29a18 100644
--- a/internal/web/templates/airport_editor.html
+++ b/internal/web/templates/airport_editor.html
@@ -208,7 +208,11 @@
-
-
-
+{{/*
+ 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.
+*/}}
+
+
+
{{ end }}
diff --git a/webjs/airport-editor/map-layers.test.js b/webjs/airport-editor/map-layers.test.js
index e99535a..1a525fa 100644
--- a/webjs/airport-editor/map-layers.test.js
+++ b/webjs/airport-editor/map-layers.test.js
@@ -26,13 +26,15 @@ import {
VERTEX_HIT_PX,
VERTEX_PANE,
MAP_CLICK_SUPPRESS_MS,
+ FEATURE_CLICK_SUPPRESS_MS,
buildVertexHandleOptions,
buildVertexHandleIconOptions,
findNearestVertexPx,
+ shouldSuppressMapClick,
+ stopLeafletClickBubble,
pointsToLatLngs,
applySurfaceLatLngs,
patchVertexPoints,
- shouldSuppressMapClick,
} from '../../internal/web/static/js/openfsd/airport-editor/map-layers.js';
import {
createEmptyDocument,
@@ -306,6 +308,54 @@ test('shouldSuppressMapClick matrix (K5: suppress ≠ block render)', () => {
shouldSuppressMapClick({ dragging: false, dragEndedAt: 0, suppressMs: ms }, 5000),
false,
);
+
+ // Feature click must suppress the trailing map click (selection stick fix)
+ assert.equal(FEATURE_CLICK_SUPPRESS_MS, 100);
+ assert.equal(
+ shouldSuppressMapClick(
+ {
+ dragging: false,
+ dragEndedAt: 0,
+ suppressMs: ms,
+ featureClickAt: 2000,
+ featureSuppressMs: FEATURE_CLICK_SUPPRESS_MS,
+ },
+ 2000 + 50,
+ ),
+ true,
+ );
+ assert.equal(
+ shouldSuppressMapClick(
+ {
+ dragging: false,
+ dragEndedAt: 0,
+ suppressMs: ms,
+ featureClickAt: 2000,
+ featureSuppressMs: FEATURE_CLICK_SUPPRESS_MS,
+ },
+ 2000 + FEATURE_CLICK_SUPPRESS_MS,
+ ),
+ false,
+ );
+});
+
+test('stopLeafletClickBubble sets originalEvent._stopped (Leaflet map bubble guard)', () => {
+ const dom = {
+ stopPropagation() {
+ this.stoppedNative = true;
+ },
+ preventDefault() {
+ this.prevented = true;
+ },
+ };
+ const ev = { originalEvent: dom };
+ stopLeafletClickBubble(ev, null);
+ assert.equal(dom._stopped, true);
+ assert.equal(dom.stoppedNative, true);
+ assert.equal(dom.prevented, true);
+ // no throw on empty
+ stopLeafletClickBubble(null, null);
+ stopLeafletClickBubble({}, null);
});
test('buildVertexHandleIconOptions: iconSize/iconAnchor symmetry', () => {
@@ -334,7 +384,7 @@ test('buildVertexHandleOptions: visual-only (capture-phase owns drag)', () => {
});
test('findNearestVertexPx: hit-test matrix (RCA capture-phase grab)', () => {
- assert.ok(VERTEX_HIT_PX >= 12);
+ assert.ok(VERTEX_HIT_PX >= 16);
const verts = [
{ x: 100, y: 100 },
{ x: 200, y: 100 },