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', () => {