airport-editor: restore kind colors on vertex nodes

Vertex circleMarkers now use the surfaceStyle palette (taxi blue-gray,
runway near-black, hold orange, parking green) so types stay distinct
while keeping centered grab handles and drag behavior.
This commit is contained in:
Reese Norris
2026-07-27 15:51:48 -04:00
parent d80023d634
commit 87b9a508f3
3 changed files with 85 additions and 26 deletions

View File

@@ -236,31 +236,48 @@ export const VERTEX_PANE = 'aptedVertex';
/**
* Pure: Leaflet circleMarker options for a vertex node (always lat/lng-centered).
* Colors follow surfaceStyle kind palette so taxi / rwy / hold / park stay distinct.
* Drag is NOT Marker.draggable — OverlayController uses capture-phase map hit-test.
*
* @param {number} vertexIndex
* @param {{ selected?: boolean, dragging?: boolean }} [state]
* @param {{ kind?: string, selected?: boolean, dragging?: boolean }} [state]
* @returns {object}
*/
export function buildVertexHandleStyle(vertexIndex, state = {}) {
const selected = !!state.selected;
const dragging = !!state.dragging;
const kind = state.kind || SurfaceTaxiway;
// Kind colors always (not selection-blue) so types stay readable when all nodes show.
const base = surfaceStyle(kind, false);
const stroke = base.color;
// Polylines: white disc + kind stroke. Parking: green fill like parking markers.
let fillColor = '#ffffff';
let fillOpacity = 1;
if (kind === SurfaceParking) {
fillColor = base.fillColor || '#40916c';
fillOpacity = 0.95;
}
if (dragging) {
// Slightly emphasize without losing kind hue.
fillColor = kind === SurfaceParking ? '#7dcea0' : '#ffffff';
}
return {
radius: VERTEX_HANDLE_RADIUS,
radius: VERTEX_HANDLE_RADIUS + (selected ? 1 : 0) + (dragging ? 1 : 0),
// Interactive false: pointer events pass through to map capture hit-test.
interactive: false,
bubblingMouseEvents: false,
weight: dragging ? 2 : 1.5,
weight: dragging ? 2.5 : selected ? 2 : 1.5,
opacity: 1,
fillOpacity: 1,
color: dragging ? '#1a3a60' : selected ? '#2a5a90' : '#4a7ab0',
fillColor: dragging ? '#d0e4f8' : selected ? '#e8f0fa' : '#ffffff',
fillOpacity,
color: stroke,
fillColor,
className:
'apted-vertex-handle' +
` apted-vertex-${kindShort(kind)}` +
(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,
_kind: kind,
};
}
@@ -1114,9 +1131,12 @@ export class OverlayController {
mapDraggingWasEnabled,
};
// Visual feedback on the centered circleMarker.
// Visual feedback on the centered circleMarker (keep kind color).
if (handle && typeof handle.setStyle === 'function') {
handle.setStyle(buildVertexHandleStyle(vertexIndex, { selected: true, dragging: true }));
const kind = this._airport?.surfaces?.[surfaceIndex]?.kind;
handle.setStyle(
buildVertexHandleStyle(vertexIndex, { kind, selected: true, dragging: true }),
);
}
// Do not snap vertex to cursor on mousedown — only move once the pointer moves.
@@ -1152,7 +1172,14 @@ export class OverlayController {
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 kind = this._airport?.surfaces?.[si]?.kind;
h.setStyle(
buildVertexHandleStyle(vi, {
kind,
selected: stillSelected,
dragging: false,
}),
);
}
const ll = this._latLngFromPointerEvent(ev) || (h && h.getLatLng && h.getLatLng());
@@ -1194,6 +1221,7 @@ export class OverlayController {
const pts = surface.points || [];
const selected =
this._selection?.type === 'surface' && this._selection.index === surfaceIndex;
const kind = surface.kind;
for (let vi = 0; vi < pts.length; vi++) {
const p = pts[vi];
@@ -1201,7 +1229,7 @@ export class OverlayController {
// circleMarker: geographic center === visual center (no iconAnchor).
const handle = L.circleMarker(
[p.lat, p.lon],
buildVertexHandleStyle(vi, { selected, dragging: false }),
buildVertexHandleStyle(vi, { kind, selected, dragging: false }),
);
handle.addTo(this.vertexGroup);
this._handleByKey.set(`${surfaceIndex}:${vi}`, handle);

View File

@@ -213,7 +213,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-6"></script>
<script src="/static/js/openfsd/leaflet.rotatedmarker.js?v=vertex-edit-6"></script>
<script type="module" src="/static/js/openfsd/airport-editor/main.js?v=vertex-edit-6"></script>
<script src="/static/js/leaflet.js?v=vertex-edit-7"></script>
<script src="/static/js/openfsd/leaflet.rotatedmarker.js?v=vertex-edit-7"></script>
<script type="module" src="/static/js/openfsd/airport-editor/main.js?v=vertex-edit-7"></script>
{{ end }}

View File

@@ -361,21 +361,52 @@ test('stopLeafletClickBubble sets originalEvent._stopped (Leaflet map bubble gua
stopLeafletClickBubble({}, null);
});
test('buildVertexHandleStyle: circleMarker centered on lat/lng (no iconAnchor)', () => {
test('buildVertexHandleStyle: kind colors match surface palette', () => {
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 });
const taxi = buildVertexHandleStyle(0, { kind: SurfaceTaxiway });
const rwy = buildVertexHandleStyle(0, { kind: SurfaceRunway });
const hold = buildVertexHandleStyle(0, { kind: SurfaceHold });
const park = buildVertexHandleStyle(0, { kind: SurfaceParking });
// Differentiated strokes (same palette as surfaceStyle unselected).
assert.equal(taxi.color, surfaceStyle(SurfaceTaxiway, false).color);
assert.equal(rwy.color, surfaceStyle(SurfaceRunway, false).color);
assert.equal(hold.color, surfaceStyle(SurfaceHold, false).color);
assert.equal(park.color, surfaceStyle(SurfaceParking, false).color);
assert.notEqual(taxi.color, rwy.color);
assert.notEqual(taxi.color, hold.color);
assert.notEqual(hold.color, park.color);
// Taxi/rwy/hold: white fill disc; parking: green fill like parking markers.
assert.equal(taxi.fillColor, '#ffffff');
assert.equal(rwy.fillColor, '#ffffff');
assert.equal(hold.fillColor, '#ffffff');
assert.equal(park.fillColor, surfaceStyle(SurfaceParking, false).fillColor);
assert.equal(taxi.interactive, false);
assert.match(taxi.className, /apted-vertex-taxi/);
assert.match(rwy.className, /apted-vertex-rwy/);
assert.match(hold.className, /apted-vertex-hold/);
assert.match(park.className, /apted-vertex-park/);
const sel = buildVertexHandleStyle(0, {
kind: SurfaceTaxiway,
selected: true,
});
assert.match(sel.className, /is-selected/);
const drag = buildVertexHandleStyle(0, { selected: true, dragging: true });
assert.equal(sel.color, taxi.color); // selection keeps kind hue
assert.ok(sel.radius > taxi.radius);
const drag = buildVertexHandleStyle(0, {
kind: SurfaceHold,
selected: true,
dragging: true,
});
assert.match(drag.className, /is-dragging/);
// Legacy divIcon helpers still export centered anchors if ever reused.
assert.equal(drag.color, hold.color);
const icon = buildVertexHandleIconOptions();
assert.equal(icon.iconAnchor[0] * 2, icon.iconSize[0]);
assert.equal(buildVertexHandleOptions(2).interactive, false);