Files
gods-eye-view/src/layers/vessels/selection.js
T

248 lines
8.7 KiB
JavaScript

import * as Cesium from 'cesium';
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
export function createSelection({
vesselState,
services,
parts: components,
layer,
options,
}) {
const { state } = vesselState;
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
const { requestWorldFocus } = services.worldFocus;
const {
selectEntityContext,
registerEntityContext,
clearSelectedEntityContextForLayer,
} = services.context;
const aisLiveVesselsLayer = layer;
function installInteraction(viewer) {
if (state.clickHandler || !viewer) return;
const handler = state.interactionHandlerFactory
? state.interactionHandlerFactory(viewer)
: new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
bindVesselInteraction(
viewer,
handler,
state.interactionKeyTarget || document,
);
}
function bindVesselInteraction(viewer, handler, keyTarget) {
state.clickHandler = handler;
handler.setInputAction((click) => {
if (!state.enabled) return;
const picked = viewer.scene.pick(click.position);
const pickedId = resolvePickId(picked);
let record = pickedId ? state.vesselMap.get(pickedId) : null;
const rawId = picked?.id ?? picked?.primitive?.id;
const ownRecordPick =
rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi');
// An own-layer record without a live map key is a strict no-op (FB-1
// residual). Trails carry no layer identity and hug their contacts, so any
// `gev-trail:*` pick is also a no-op. Every other non-vessel pick — sibling
// unowned scene picks dismiss the current vessel inspection.
if (ownRecordPick && (!pickedId || !record)) return;
if (pickedId && !record && String(pickedId).startsWith('gev-trail:'))
return;
// A sibling layer already owns this click. Preserve the current vessel
// selection and do not compete with its camera command.
if (pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId)) return;
// Cards are painted on a pointer-events:none canvas, so the scene pick is
// usually terrain behind the card. Resolve against the host's current
// actionable hit rectangles before treating the click as empty space.
const cardHit = !record
? vesselState._vesselOverlayHost.hitTest?.(
click.position?.x,
click.position?.y,
{
sourceId: VESSEL_OVERLAY_SOURCE_ID,
},
)
: null;
if (!record && cardHit) {
const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
? cardHit.entryId.slice('vessel:'.length)
: null;
record = mmsi ? state.vesselMap.get(mmsi) || null : null;
// A stale card id is not empty terrain and must not clear a newer
// selection. The next paint will evict its hit rectangle.
if (!record) return;
}
if (record) {
// A valid sprite or card click always transfers the camera exactly once,
// including a second click on the already-selected vessel.
selectAndFocusVessel(record);
} else {
const transition = components.queries.reduceVesselSelection({
selectedMmsi: state.selectedRecord?.mmsi,
pickedMmsi: null,
gesture: 'click',
});
if (transition.action === 'deselect') clearVesselInspection();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
state.keyTarget = keyTarget;
state.keydownHandler = onVesselKeyDown;
keyTarget.addEventListener('keydown', state.keydownHandler);
// Vessels never set viewer.trackedEntity, so any new tracked entity belongs
// to another layer and takes interaction ownership of the scene.
state.trackedEntityRemover = viewer.trackedEntityChanged.addEventListener(
() => {
if (viewer.trackedEntity && state.selectedRecord)
clearVesselInspection();
},
);
}
/** Select one live vessel and request one UI-owned camera transfer. */
function selectAndFocusVessel(record) {
if (!record?.mmsi) return false;
const transition = components.queries.reduceVesselSelection({
selectedMmsi: state.selectedRecord?.mmsi,
pickedMmsi: record.mmsi,
gesture: 'click',
});
if (transition.action === 'select') selectVessel(record);
requestWorldFocus({
kind: 'vessel',
id: record.mmsi,
label: record.name || record.mmsi,
position: record.billboard?.position || record.position,
});
return true;
}
function removeVesselInteraction() {
if (state.clickHandler) {
state.clickHandler.destroy();
state.clickHandler = null;
}
if (state.keyTarget && state.keydownHandler) {
state.keyTarget.removeEventListener('keydown', state.keydownHandler);
}
state.keyTarget = null;
state.keydownHandler = null;
if (state.trackedEntityRemover) {
state.trackedEntityRemover();
state.trackedEntityRemover = null;
}
}
function onVesselKeyDown(event) {
if (!state.enabled || event.key !== 'Escape') return;
const transition = components.queries.reduceVesselSelection({
selectedMmsi: state.selectedRecord?.mmsi,
gesture: 'escape',
});
if (transition.action === 'deselect') {
clearVesselInspection();
}
}
function selectVessel(record) {
if (!record?.mmsi) return;
const reuseTrail = state.trailMmsi === record.mmsi;
clearSelection({ preserveTrail: reuseTrail });
state.selectedRecord = record;
record.missedRefreshes = 0;
if (record.billboard) {
record.billboard.image = components.rendering.shipIcon(record, true);
record.billboard.scale = components.rendering.shipScale(record) * 1.2;
}
// Rebuild the card set immediately so the full-detail card appears on the
// click, not up to VISIBILITY_UPDATE_MS later.
components.rendering.updateVisibility(true);
components.cards.updateSelectedVesselHud(record);
if (registerSelectedContext(record)) {
selectEntityContext(record);
}
// Track-history trail (PRD F3/F4): seed with the current position + async
// backfill from the server-side per-MMSI ring buffer.
if (reuseTrail) {
components.tracking.appendSelectedVesselTrailFix(record);
} else {
components.tracking.startSelectedVesselTrail(record);
}
}
/**
* Register (or refresh) the selected vessel in the shared context store so
* the realtime/voice layer can describe what the user has selected.
* @param {Object} record - Selected vessel record.
* @returns {Object|null} The context record, or null if registration failed.
*/
function registerSelectedContext(record) {
if (!record?.mmsi) return null;
try {
return registerEntityContext(record, {
id: `ais-${record.mmsi}`,
layerId: 'ais-live-vessels',
layerName: 'Live AIS Vessels',
source: aisLiveVesselsLayer.source,
label: components.cards.displayVesselName(record),
latitude: record.lat,
longitude: record.lon,
properties: {
mmsi: record.mmsi,
type: record.type,
speedKt: record.speed,
course: record.course,
destination: record.destination,
},
});
} catch (error) {
console.warn('[Data:ais-live-vessels] context register failed', error);
return null;
}
}
function clearSelection({ preserveTrail = false, evicted = false } = {}) {
const record = state.selectedRecord;
if (record?.billboard) {
record.billboard.image = components.rendering.shipIcon(record, false);
record.billboard.scale = components.rendering.shipScale(record);
}
state.selectedRecord = null;
// Drop the full-detail card right away (no-op when the layer is disabled —
// disable() clears the entry set itself).
if (record && state.enabled) components.rendering.updateVisibility(true);
if (!preserveTrail) components.tracking.clearSelectedVesselTrail();
try {
clearSelectedEntityContextForLayer('ais-live-vessels', { evicted });
} catch (error) {
console.warn('[Data:ais-live-vessels] context clear failed', error);
}
}
/**
* @param {object} [options] Clear origin.
* @param {boolean} [options.evicted=false] The vessel aged out of the feed
* rather than being deselected.
*/
function clearVesselInspection({ evicted = false } = {}) {
clearSelection({ evicted });
components.cards.resetSelectedVesselHud();
}
return {
installInteraction,
bindVesselInteraction,
selectAndFocusVessel,
removeVesselInteraction,
onVesselKeyDown,
selectVessel,
registerSelectedContext,
clearSelection,
clearVesselInspection,
};
}