mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Format extracted vessel components
This commit is contained in:
1 parent
2f4839e8f2
commit
a0b2d9f6aa
17 files changed
+2693
-2385
No files matched your search
+227
-209
@@ -1,229 +1,247 @@
|
||||
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;
|
||||
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');
|
||||
|
||||
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);
|
||||
}
|
||||
// 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;
|
||||
|
||||
|
||||
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)
|
||||
// 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;
|
||||
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 && 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,
|
||||
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',
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[Data:ais-live-vessels] context register failed', error);
|
||||
return null;
|
||||
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 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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} [options] Clear origin.
|
||||
* @param {boolean} [options.evicted=false] The vessel aged out of the feed
|
||||
* rather than being deselected.
|
||||
*/
|
||||
/**
|
||||
* 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 clearVesselInspection({ evicted = false } = {}) {
|
||||
clearSelection({ evicted });
|
||||
components.cards.resetSelectedVesselHud();
|
||||
}
|
||||
return { installInteraction, bindVesselInteraction, selectAndFocusVessel, removeVesselInteraction, onVesselKeyDown, selectVessel, registerSelectedContext, clearSelection, clearVesselInspection };
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user