diff --git a/internal/web/static/css/openfsd/airport-editor.css b/internal/web/static/css/openfsd/airport-editor.css
index b4d088d..c122f63 100644
--- a/internal/web/static/css/openfsd/airport-editor.css
+++ b/internal/web/static/css/openfsd/airport-editor.css
@@ -254,46 +254,16 @@
}
/*
- * Vertex handle pane (Leaflet createPane("aptedVertex") → .leaflet-aptedVertex-pane).
- * Entire pane is pointer-events:none — grab is capture-phase pixel hit-test on
- * the map container so Map.Drag never steals the mousedown.
+ * Vertex nodes are L.circleMarker (SVG) — always centered on the lat/lng.
+ * Do not use divIcon knobs: CSS margin:0 !important was zeroing Leaflet's
+ * iconAnchor offsets (marginLeft/Top) and shifted discs off the true point.
+ * Grab is capture-phase pixel hit-test on the map container.
*/
-.leaflet-aptedVertex-pane {
- z-index: 660;
- pointer-events: none !important;
-}
-.leaflet-aptedVertex-pane .leaflet-marker-icon,
-.leaflet-aptedVertex-pane .apted-vertex-handle {
- pointer-events: none !important; /* visuals only */
-}
-
-/* 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: 10px !important;
- height: 10px !important;
- margin: 0 !important;
- 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);
+.apted-vertex-handle {
cursor: grab;
- box-sizing: border-box !important;
}
-.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 {
+.apted-vertex-handle.is-dragging {
cursor: grabbing;
- border-color: #1a3a60 !important;
- background: #d0e4f8 !important;
}
/* Inspector form density */
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 e5d4679..23d195f 100644
--- a/internal/web/static/js/openfsd/airport-editor/map-layers.js
+++ b/internal/web/static/js/openfsd/airport-editor/map-layers.js
@@ -202,8 +202,14 @@ export function escapeHtml(s) {
.replace(/'/g, ''');
}
-/** Keep in sync with .apted-vertex-handle width/height in airport-editor.css */
-export const VERTEX_HANDLE_PX = 10;
+/**
+ * Visual radius (px) of vertex circleMarkers. circleMarker is always centered on
+ * the lat/lng — do not use divIcon/L.marker for nodes (iconAnchor/CSS margin fights).
+ */
+export const VERTEX_HANDLE_RADIUS = 5;
+
+/** @deprecated use VERTEX_HANDLE_RADIUS; kept as diameter for older tests/docs */
+export const VERTEX_HANDLE_PX = VERTEX_HANDLE_RADIUS * 2;
/**
* Pixel radius for vertex grab hit-test (capture-phase, independent of icon DOM hits).
@@ -229,29 +235,53 @@ export const FEATURE_CLICK_SUPPRESS_MS = 100;
export const VERTEX_PANE = 'aptedVertex';
/**
- * Pure: options for the vertex L.marker (visual only).
+ * Pure: Leaflet circleMarker options for a vertex node (always lat/lng-centered).
* Drag is NOT Marker.draggable — OverlayController uses capture-phase map hit-test.
* @param {number} vertexIndex
+ * @param {{ selected?: boolean, dragging?: boolean }} [state]
+ * @returns {object}
+ */
+export function buildVertexHandleStyle(vertexIndex, state = {}) {
+ const selected = !!state.selected;
+ const dragging = !!state.dragging;
+ return {
+ radius: VERTEX_HANDLE_RADIUS,
+ // Interactive false: pointer events pass through to map capture hit-test.
+ interactive: false,
+ bubblingMouseEvents: false,
+ weight: dragging ? 2 : 1.5,
+ opacity: 1,
+ fillOpacity: 1,
+ color: dragging ? '#1a3a60' : selected ? '#2a5a90' : '#4a7ab0',
+ fillColor: dragging ? '#d0e4f8' : selected ? '#e8f0fa' : '#ffffff',
+ className:
+ 'apted-vertex-handle' +
+ (selected ? ' is-selected' : '') +
+ (dragging ? ' is-dragging' : ''),
+ // title is not a path option; kept only for call-site docs
+ // (vertexIndex used so the API stays stable for tests)
+ _vertexIndex: vertexIndex,
+ };
+}
+
+/**
+ * @deprecated Prefer buildVertexHandleStyle (circleMarker). Kept for test compat.
+ * @param {number} vertexIndex
* @returns {object}
*/
export function buildVertexHandleOptions(vertexIndex) {
return {
- // Visual only — pointer drag is owned by map capture listener.
draggable: false,
- // interactive false: clicks pass through icon to map container hit-test path.
- // (We still paint the handle; grab uses latLng pixel proximity.)
interactive: false,
keyboard: false,
autoPan: false,
- zIndexOffset: 4000,
bubblingMouseEvents: false,
- pane: VERTEX_PANE,
title: `Vertex ${vertexIndex + 1} — drag to move`,
};
}
/**
- * Pure icon size/anchor for divIcon — symmetry asserted in unit tests.
+ * @deprecated Prefer buildVertexHandleStyle (circleMarker). Kept for test compat.
* @returns {{ className: string, iconSize: [number, number], iconAnchor: [number, number] }}
*/
export function buildVertexHandleIconOptions() {
@@ -259,6 +289,7 @@ export function buildVertexHandleIconOptions() {
return {
className: 'leaflet-div-icon apted-vertex-handle',
iconSize: [px, px],
+ // Center of the box — only meaningful if CSS does NOT zero Leaflet's margins.
iconAnchor: [px / 2, px / 2],
};
}
@@ -1074,15 +1105,12 @@ export class OverlayController {
mapDraggingWasEnabled,
};
- try {
- const el = handle?.getElement?.();
- if (el && el.classList) el.classList.add('is-dragging');
- } catch {
- /* ignore */
+ // Visual feedback on the centered circleMarker.
+ if (handle && typeof handle.setStyle === 'function') {
+ handle.setStyle(buildVertexHandleStyle(vertexIndex, { selected: true, dragging: true }));
}
- // Do not snap vertex to cursor on mousedown — that made the knob feel off-center.
- // Only update geometry once the pointer actually moves.
+ // Do not snap vertex to cursor on mousedown — only move once the pointer moves.
void domEv;
const onMove = (ev) => {
@@ -1112,11 +1140,10 @@ export class OverlayController {
this._onVertexPointerUp = null;
this._vertexDrag = null;
- try {
- const el = h?.getElement?.();
- if (el && el.classList) el.classList.remove('is-dragging');
- } catch {
- /* ignore */
+ if (h && typeof h.setStyle === 'function') {
+ const stillSelected =
+ this._selection?.type === 'surface' && this._selection.index === si;
+ h.setStyle(buildVertexHandleStyle(vi, { selected: stillSelected, dragging: false }));
}
const ll = this._latLngFromPointerEvent(ev) || (h && h.getLatLng && h.getLatLng());
@@ -1146,7 +1173,9 @@ export class OverlayController {
}
/**
- * Small vertex handle *visuals* for a surface (non-interactive markers).
+ * Small vertex handle *visuals* for a surface.
+ * Uses L.circleMarker so the disc is always centered on the lat/lng
+ * (divIcon + CSS margin overrides previously shifted the knob off-center).
* Grab is owned by capture-phase hit-test on the map container (any surface).
* @param {import('./model.js').Surface} surface
* @param {number} surfaceIndex
@@ -1154,22 +1183,17 @@ export class OverlayController {
_addVertexHandles(surface, surfaceIndex) {
const L = this.L;
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({
- ...iconBase,
- className: iconBase.className + (selected ? ' is-selected' : ''),
- }),
- });
+ // circleMarker: geographic center === visual center (no iconAnchor).
+ const handle = L.circleMarker(
+ [p.lat, p.lon],
+ buildVertexHandleStyle(vi, { selected, dragging: false }),
+ );
handle.addTo(this.vertexGroup);
this._handleByKey.set(`${surfaceIndex}:${vi}`, handle);
}
diff --git a/internal/web/templates/airport_editor.html b/internal/web/templates/airport_editor.html
index 01da803..18d85f1 100644
--- a/internal/web/templates/airport_editor.html
+++ b/internal/web/templates/airport_editor.html
@@ -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.
*/}}
-
-
-
+
+
+
{{ end }}
diff --git a/webjs/airport-editor/map-layers.test.js b/webjs/airport-editor/map-layers.test.js
index 82f6878..3c90d7a 100644
--- a/webjs/airport-editor/map-layers.test.js
+++ b/webjs/airport-editor/map-layers.test.js
@@ -23,12 +23,14 @@ import {
ESRI_TILE_URL,
ESRI_ATTRIBUTION,
VERTEX_HANDLE_PX,
+ VERTEX_HANDLE_RADIUS,
VERTEX_HIT_PX,
VERTEX_PANE,
MAP_CLICK_SUPPRESS_MS,
FEATURE_CLICK_SUPPRESS_MS,
buildVertexHandleOptions,
buildVertexHandleIconOptions,
+ buildVertexHandleStyle,
findNearestVertexPx,
findNearestVertexAcrossSurfaces,
shouldSuppressMapClick,
@@ -359,31 +361,25 @@ test('stopLeafletClickBubble sets originalEvent._stopped (Leaflet map bubble gua
stopLeafletClickBubble({}, null);
});
-test('buildVertexHandleIconOptions: iconSize/iconAnchor symmetry (centered disc)', () => {
+test('buildVertexHandleStyle: circleMarker centered on lat/lng (no iconAnchor)', () => {
+ assert.equal(VERTEX_HANDLE_RADIUS, 5);
+ assert.equal(VERTEX_HANDLE_PX, VERTEX_HANDLE_RADIUS * 2);
+ const style = buildVertexHandleStyle(2, { selected: false, dragging: false });
+ assert.equal(style.radius, VERTEX_HANDLE_RADIUS);
+ assert.equal(style.interactive, false);
+ assert.equal(style.bubblingMouseEvents, false);
+ assert.equal(style.fillOpacity, 1);
+ assert.match(style.className, /apted-vertex-handle/);
+ assert.ok(!style.className.includes('is-selected'));
+ const sel = buildVertexHandleStyle(0, { selected: true, dragging: false });
+ assert.match(sel.className, /is-selected/);
+ const drag = buildVertexHandleStyle(0, { selected: true, dragging: true });
+ assert.match(drag.className, /is-dragging/);
+ // Legacy divIcon helpers still export centered anchors if ever reused.
const icon = buildVertexHandleIconOptions();
- 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/);
-});
-
-test('buildVertexHandleOptions: visual-only (capture-phase owns drag)', () => {
- const opts = buildVertexHandleOptions(2);
- // Handles are non-interactive visuals; grab is map capture + pixel hit-test.
- assert.equal(opts.draggable, false);
- assert.equal(opts.interactive, false);
- assert.equal(opts.autoPan, false);
- assert.equal(opts.keyboard, false);
- assert.equal(opts.bubblingMouseEvents, false);
- assert.ok(opts.zIndexOffset >= 2000);
- assert.equal(opts.pane, VERTEX_PANE);
+ assert.equal(buildVertexHandleOptions(2).interactive, false);
assert.equal(VERTEX_PANE, 'aptedVertex');
- assert.match(opts.title, /Vertex 3/);
});
test('findNearestVertexAcrossSurfaces: grab without pre-select', () => {