feat(vessels): key vessels by their source's stable id, and share a selected vessel

AIS Vessels are keyed by the id their live source gives, with the MMSI as an
attribute: an MMSI can be shared, spoofed or reused, so two vessels with one
MMSI are now two vessels. Looking a vessel up or selecting it by MMSI still
finds it.

A share link made while a vessel is selected opens that vessel. A UUID vessel
id travels as 16 bytes in a new thing field (13). A shared thing that is not
reporting now says so in a sentence per kind, with its id (ICAO, NORAD, MMSI
or vessel UUID) on a second line.
This commit is contained in:
Sameh Khamis committed 2026-11-10 18:12:53 -08:00
1 parent 57ff81f723
commit cbbf55cacd
74 files changed
+2481 -586

No files matched your search

+21 -18
View File
@@ -39,13 +39,15 @@ export function createSelection({
if (!state.feed.enabled) return;
// The bridge view owns the selection: a click on open water must not
// deselect the vessel the camera is riding.
if (state.cockpitSubjectMmsi) return;
if (state.cockpitSubjectId) return;
const picked = pickContact
? pickContact(viewer.scene, click.position)
: viewer.scene.pick(click.position);
const pickedId = resolvePickId(picked);
let record = pickedId ? state.records.byMmsi.get(pickedId) : null;
let record = pickedId ? state.records.byId.get(pickedId) : null;
const rawId = picked?.id ?? picked?.primitive?.id;
// Every vessel record carries an `mmsi` field ('' when unknown); its
// key is `id`.
const ownRecordPick =
rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi');
@@ -85,10 +87,10 @@ export function createSelection({
if (cycloneBackground && cardHit?.sourceId !== VESSEL_OVERLAY_SOURCE_ID)
return;
if (!record && cardHit) {
const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
const id = String(cardHit.entryId || '').startsWith('vessel:')
? cardHit.entryId.slice('vessel:'.length)
: null;
record = mmsi ? state.records.byMmsi.get(mmsi) || null : null;
record = id ? state.records.byId.get(id) || 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;
@@ -100,8 +102,8 @@ export function createSelection({
selectAndFocusVessel(record);
} else {
const transition = components.queries.reduceVesselSelection({
selectedMmsi: state.selectedRecord?.mmsi,
pickedMmsi: null,
selectedId: state.selectedRecord?.id,
pickedId: null,
gesture: 'click',
});
if (transition.action === 'deselect') clearVesselInspection();
@@ -123,17 +125,17 @@ export function createSelection({
/** Select one live vessel and request one UI-owned camera transfer. */
function selectAndFocusVessel(record) {
if (!record?.mmsi) return false;
if (!record?.id) return false;
const transition = components.queries.reduceVesselSelection({
selectedMmsi: state.selectedRecord?.mmsi,
pickedMmsi: record.mmsi,
selectedId: state.selectedRecord?.id,
pickedId: record.id,
gesture: 'click',
});
if (transition.action === 'select') selectVessel(record);
requestWorldFocus({
kind: 'vessel',
id: record.mmsi,
label: record.name || record.mmsi,
id: record.id,
label: record.name || record.mmsi || record.id,
position:
components.rendering.getVisual(record).billboard?.position ||
components.rendering.getVisual(record).position,
@@ -160,7 +162,7 @@ export function createSelection({
function onVesselKeyDown(event) {
if (!state.feed.enabled || event.key !== 'Escape') return;
const transition = components.queries.reduceVesselSelection({
selectedMmsi: state.selectedRecord?.mmsi,
selectedId: state.selectedRecord?.id,
gesture: 'escape',
});
if (transition.action === 'deselect') {
@@ -169,8 +171,8 @@ export function createSelection({
}
function selectVessel(record) {
if (!record?.mmsi) return;
const reuseTrail = state.trailMmsi === record.mmsi;
if (!record?.id) return;
const reuseTrail = state.trailId === record.id;
clearSelection({ preserveTrail: reuseTrail });
state.selectedRecord = record;
record.missedRefreshes = 0;
@@ -188,7 +190,7 @@ export function createSelection({
selectEntityContext(record);
}
// Track-history trail (PRD F3/F4): seed with the current position + async
// backfill from the server-side per-MMSI ring buffer.
// backfill from the source's recent track for this vessel.
if (reuseTrail) {
components.tracking.appendSelectedVesselTrailFix(record);
} else {
@@ -204,10 +206,10 @@ export function createSelection({
*/
function registerSelectedContext(record) {
if (!record?.mmsi) return null;
if (!record?.id) return null;
try {
return registerEntityContext(record, {
id: `ais-${record.mmsi}`,
id: `ais-${record.id}`,
layerId: 'ais-live-vessels',
layerName: 'Live AIS Vessels',
source: aisLiveVesselsLayer.source,
@@ -215,7 +217,8 @@ export function createSelection({
latitude: record.lat,
longitude: record.lon,
properties: {
mmsi: record.mmsi,
vesselId: record.id,
mmsi: record.mmsi || null,
type: record.type,
speedKt: record.speed,
course: record.course,