mirror of
https://github.com/renorris/openfsd
synced 2026-08-10 19:36:08 +08:00
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:
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user