diff --git a/internal/web/static/css/openfsd/airport-editor.css b/internal/web/static/css/openfsd/airport-editor.css index 8aebc32..b4d088d 100644 --- a/internal/web/static/css/openfsd/airport-editor.css +++ b/internal/web/static/css/openfsd/airport-editor.css @@ -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 */ diff --git a/internal/web/static/js/openfsd/airport-editor/main.js b/internal/web/static/js/openfsd/airport-editor/main.js index 1ffba1a..be3fad9 100644 --- a/internal/web/static/js/openfsd/airport-editor/main.js +++ b/internal/web/static/js/openfsd/airport-editor/main.js @@ -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 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 8e20df8..e5d4679 100644 --- a/internal/web/static/js/openfsd/airport-editor/map-layers.js +++ b/internal/web/static/js/openfsd/airport-editor/map-layers.js @@ -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); diff --git a/internal/web/templates/airport_editor.html b/internal/web/templates/airport_editor.html index 7f29a18..01da803 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 1a525fa..82f6878 100644 --- a/webjs/airport-editor/map-layers.test.js +++ b/webjs/airport-editor/map-layers.test.js @@ -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 },