mirror of
https://github.com/renorris/openfsd
synced 2026-08-10 19:36:08 +08:00
airport-editor: center vertex nodes with circleMarker
divIcon knobs were offset because CSS margin:0 !important wiped Leaflet iconAnchor margins (marginLeft/Top), pinning the disc corner on the lat/lng. Use L.circleMarker instead so the visual is always centered on the point.
This commit is contained in:
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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-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>
|
||||
<script src="/static/js/leaflet.js?v=vertex-edit-5"></script>
|
||||
<script src="/static/js/openfsd/leaflet.rotatedmarker.js?v=vertex-edit-5"></script>
|
||||
<script type="module" src="/static/js/openfsd/airport-editor/main.js?v=vertex-edit-5"></script>
|
||||
{{ end }}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user