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

+12
View File
@@ -6,6 +6,7 @@ import assert from 'node:assert/strict';
import { mapAnalystRecord } from './aisLiveVessels.js';
const FULL_RECORD = {
id: '353136000',
mmsi: '353136000',
name: 'EVER GIVEN',
lat: 29.55,
@@ -20,6 +21,7 @@ test('ais analyst record: full record maps every contract field', () => {
const r = mapAnalystRecord(FULL_RECORD);
assert.deepEqual(r, {
id: 'EVER GIVEN',
vesselId: '353136000',
mmsi: '353136000',
name: 'EVER GIVEN',
lat: 29.55,
@@ -38,6 +40,16 @@ test('ais analyst record: nameless vessel falls back to mmsi id', () => {
assert.equal(r.name, null);
});
test('ais analyst record: the key is the source id; the MMSI rides along when known', () => {
const shared = mapAnalystRecord({ ...FULL_RECORD, id: 'provider-a' });
assert.equal(shared.vesselId, 'provider-a');
assert.equal(shared.mmsi, '353136000');
const noMmsi = mapAnalystRecord({ ...FULL_RECORD, id: 'provider-b', mmsi: '', name: '' });
assert.equal(noMmsi.vesselId, 'provider-b');
assert.equal(noMmsi.mmsi, null);
assert.equal(noMmsi.id, 'provider-b', 'a nameless vessel with no MMSI is labelled by its id');
});
test('ais analyst record: empty strings and NaN become null, never undefined', () => {
const r = mapAnalystRecord({ mmsi: '', name: 'TUG', speed: NaN, type: '', destination: '' });
assert.equal(r.mmsi, null);
+64 -23
View File
@@ -287,7 +287,7 @@ test('zero accepted rows preserve warm vessel selection, trail, and freshness ti
records: [record],
selectedRecord: record,
trail,
trailMmsi: record.mmsi,
trailId: record.mmsi,
trailPositions: [POS],
loaded: true,
lastUpdate: 456,
@@ -314,8 +314,8 @@ test('zero accepted rows preserve warm vessel selection, trail, and freshness ti
lastMessageAt: 789,
rawRowCount: 1,
acceptedRowCount: 0,
selectedMmsi: record.mmsi,
trailMmsi: record.mmsi,
selectedId: record.mmsi,
trailId: record.mmsi,
trailPositionCount: 1,
sessionId: _getVesselFeedStateForTest().sessionId,
firstConnectPhase: 'idle',
@@ -565,7 +565,7 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
records: [replacement],
selectedRecord: replacement,
trail: replacementTrail,
trailMmsi: replacement.mmsi,
trailId: replacement.mmsi,
trailPositions: [POS],
loaded: true,
lastUpdate: 200,
@@ -581,7 +581,7 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
let feed = _getVesselFeedStateForTest();
assert.equal(feed.loading, true, 'old finally must not clear replacement loading ownership');
assert.equal(feed.lastUpdate, 200, 'old response must not advance replacement freshness');
assert.equal(feed.selectedMmsi, replacement.mmsi);
assert.equal(feed.selectedId, replacement.mmsi);
second.resolve(jsonResponse({ status: 'open', lastMessageAt: null, rows: [] }));
await replacementLoad;
@@ -590,8 +590,8 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
assert.equal(feed.lastUpdate, 200);
assert.equal(feed.error, 'awaiting first AIS message…');
assert.equal(feed.stale, true);
assert.equal(feed.selectedMmsi, replacement.mmsi);
assert.equal(feed.trailMmsi, replacement.mmsi);
assert.equal(feed.selectedId, replacement.mmsi);
assert.equal(feed.trailId, replacement.mmsi);
assert.equal(replacementTrail.clearCalls, 0);
} finally {
globalThis.fetch = priorFetch;
@@ -784,21 +784,21 @@ test('vessel focus wire restores a hidden sprite before releasing the active pas
test('vessel selection: empty-space click requests deselection', () => {
assert.deepEqual(
reduceVesselSelection({ selectedMmsi: '353136000', gesture: 'click' }),
reduceVesselSelection({ selectedId: '353136000', gesture: 'click' }),
{ action: 'deselect' },
);
});
test('vessel selection: no selection plus empty-space click is a no-op', () => {
assert.deepEqual(
reduceVesselSelection({ selectedMmsi: null, pickedMmsi: null, gesture: 'click' }),
reduceVesselSelection({ selectedId: null, pickedId: null, gesture: 'click' }),
{ action: 'none' },
);
});
test('vessel selection: Escape deselects only when this layer owns a selection', () => {
assert.deepEqual(
reduceVesselSelection({ selectedMmsi: 353136000, gesture: 'escape' }),
reduceVesselSelection({ selectedId: 353136000, gesture: 'escape' }),
{ action: 'deselect' },
);
assert.deepEqual(
@@ -810,16 +810,16 @@ test('vessel selection: Escape deselects only when this layer owns a selection',
test('vessel selection: another vessel replaces selection; same vessel is a no-op', () => {
assert.deepEqual(
reduceVesselSelection({
selectedMmsi: '353136000',
pickedMmsi: '367123450',
selectedId: '353136000',
pickedId: '367123450',
gesture: 'click',
}),
{ action: 'select' },
);
assert.deepEqual(
reduceVesselSelection({
selectedMmsi: '353136000',
pickedMmsi: '353136000',
selectedId: '353136000',
pickedId: '353136000',
gesture: 'click',
}),
{ action: 'none' },
@@ -834,8 +834,9 @@ test('vessel selection: another vessel replaces selection; same vessel is a no-o
const POS = Cesium.Cartesian3.fromDegrees(4.05, 51.93, 3);
// Like the layer's records, a vessel with no id of its own is keyed by its MMSI.
function makeRecord(overrides = {}) {
return {
const record = {
name: 'EVER GIVEN',
mmsi: '353136000',
type: 'Container Ship',
@@ -849,6 +850,8 @@ function makeRecord(overrides = {}) {
billboard: null,
...overrides,
};
if (!Object.hasOwn(overrides, 'id')) record.id = record.mmsi;
return record;
}
function makeTrailSpy() {
@@ -948,7 +951,7 @@ function installWireHarness(picked, stateOverrides = {}) {
records: [record],
selectedRecord: record,
trail,
trailMmsi: record.mmsi,
trailId: record.mmsi,
trailPositions: [POS],
interactionHandlerFactory,
interactionKeyTarget: keyTarget,
@@ -983,7 +986,7 @@ test('vessel interaction wire: trail pick does not deselect', () => {
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
assert.equal(harness.trail.clearCalls, 0);
assert.equal(_getVesselStateForTest().trailMmsi, harness.record.mmsi);
assert.equal(_getVesselStateForTest().trailId, harness.record.mmsi);
} finally {
harness.cleanup();
}
@@ -1213,7 +1216,7 @@ test('vessel interaction lifecycle: disable detaches input and enable reinstalls
selectedRecord: harness.record,
enabled: false,
trail: harness.trail,
trailMmsi: harness.record.mmsi,
trailId: harness.record.mmsi,
trailPositions: [POS],
interactionHandlerFactory: harness.interactionHandlerFactory,
interactionKeyTarget: harness.keyTarget,
@@ -1287,7 +1290,7 @@ test('vessel trail lifecycle: deselect clears the selected-vessel trail', () =>
aisLiveVesselsLayer.clearSelection();
assert.equal(harness.trail.clearCalls, 1);
assert.deepEqual(_getVesselStateForTest(), {
trailMmsi: null,
trailId: null,
trailPositionCount: 0,
vesselCount: 1,
});
@@ -1302,14 +1305,14 @@ test('vessel trail lifecycle: reconciliation eviction clears an orphaned trail',
_setVesselStateForTest({
records: [record],
trail,
trailMmsi: record.mmsi,
trailId: record.mmsi,
trailPositions: [POS],
});
try {
_reconcileVesselsForTest({}, []);
assert.equal(trail.clearCalls, 1);
assert.deepEqual(_getVesselStateForTest(), {
trailMmsi: null,
trailId: null,
trailPositionCount: 0,
vesselCount: 0,
});
@@ -1566,6 +1569,28 @@ test('vessel interaction wire: selecting a vessel by click requests a camera tra
}
});
test('vessel interaction wire: a vessel with an id and no MMSI is selected by click', () => {
const idOnly = makeRecord({ id: 'provider-7', mmsi: '', name: 'NO MMSI' });
const harness = installWireHarness(
{ id: idOnly },
{ records: [idOnly], selectedRecord: null, trailId: null },
);
const requests = [];
harness.windowTarget.addEventListener(
WORLD_FOCUS_REQUEST_EVENT,
(event) => requests.push(event.detail),
);
try {
harness.handler.click({ position: { x: 10, y: 20 } });
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.id, 'provider-7');
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, '');
assert.equal(requests[0]?.id, 'provider-7');
assert.match(harness.hud.readout.text, /MMSI: -- /);
} finally {
harness.cleanup();
}
});
/**
* Overlay-host recorder whose hitTest answers with one card id. The real host
* only publishes rects for `interactive` entries, so a stub returning a hit is
@@ -1689,11 +1714,27 @@ for (const card of ['live', 'stale', 'absent', 'covered']) {
});
}
test('vessel card policy: only MMSI-keyed cards publish a hit rect', () => {
test('vessel card policy: only keyed cards publish a hit rect', () => {
const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord()));
assert.equal(keyed.interactive, true);
const unkeyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord({ mmsi: '' })));
assert.equal(unkeyed.interactive, false, 'an unkeyed card has no record to select');
const idOnly = buildVesselCard(makeRecord({ id: 'provider-7', mmsi: '' }));
assert.equal(applyVesselOverlayPolicy(idOnly).interactive, true, 'a source id is a key');
assert.equal(idOnly.id, 'vessel:provider-7');
});
test('vessel cards are keyed by id and show the MMSI only when known', () => {
const shared = buildSelectedVesselCard(makeRecord({ id: 'hull-b' }));
assert.equal(shared.id, 'vessel:hull-b');
assert.equal(shared.actionable, true);
assert.match(shared.details.at(-1), /^MMSI 353136000 · /);
const unknown = buildSelectedVesselCard(
makeRecord({ id: 'provider-7', mmsi: '', name: 'provider-7' }),
);
assert.equal(unknown.actionable, true);
assert.match(unknown.details.at(-1), /^MMSI -- · /);
assert.equal(unknown.title, 'VESSEL', 'a name that is only the id is no name');
});
test('vessel interaction wire: deselecting never moves the camera', () => {
@@ -1740,7 +1781,7 @@ test('vessel selection passes the opaque source reference to optional history',
getSnapshot: async () => ({ records: [] }),
getTrack: async (reference, options) => { requests.push({ reference, options }); return { records: [] }; },
});
const harness = installWireHarness(undefined, { selectedRecord: null, trailMmsi: null });
const harness = installWireHarness(undefined, { selectedRecord: null, trailId: null });
try {
harness.record.reference = 'opaque:test-reference';
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
+1 -1
View File
@@ -276,7 +276,7 @@ test('a click on neither sprite nor card clears without moving the camera', () =
const SIBLING_PICKS = [
['flights', 'a1b2c3', { id: 'a1b2c3' }],
['flights', 'a1b2c3', { primitive: { id: 'a1b2c3' } }],
['ais-live-vessels', '353136000', { id: { mmsi: '353136000', name: 'EVER GIVEN' } }],
['ais-live-vessels', '353136000', { id: { id: '353136000', mmsi: '353136000', name: 'EVER GIVEN' } }],
['cctv', 'atx-cam-3', { id: { id: 'atx-cam-3', name: 'entity-like' } }],
['satellites', '25544', { id: 25544 }],
];
+1 -1
View File
@@ -1807,7 +1807,7 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
[
'aisLiveVessels',
readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)),
/hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byMmsi\s*\|\|\s*state\.records\.byMmsi\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
/hasContact\(\s*id,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byId\s*\|\|\s*state\.records\.byId\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
],
]) {
assert.match(
+2 -2
View File
@@ -61,9 +61,9 @@ export function formatAwarenessLabel(value) {
value.callsign,
value.name,
value.label,
value.mmsi,
value.id,
value.icao24,
value.mmsi,
]) {
const label = formatAwarenessLabel(candidate);
if (label !== '—') return label;
@@ -154,7 +154,7 @@ export function getAwarenessNavigationTargets(
? cohort.summary.nearest
: [];
for (const item of sourceItems) {
const id = item?.icao24 || item?.mmsi || item?.id;
const id = item?.icao24 || item?.id || item?.mmsi;
if (!id) continue;
const key = `${layerId}:${id}`;
if (key === `${subject?.layerId}:${subject?.id}`) continue;
+2 -4
View File
@@ -21,7 +21,7 @@ const _owners = new Map();
*
* Layers use heterogeneous pick ids: flights/military/bikeshare/CCTV use
* strings, satellites use numeric NORAD catalog ids, live AIS vessels attach
* the vessel record OBJECT (identity = its `mmsi`), and entity picks surface
* the vessel record OBJECT (identity = its `id`), and entity picks surface
* the Cesium Entity (identity = its string `id`). Everything is coerced to a
* String so predicates match against one canonical form.
*
@@ -33,9 +33,7 @@ export function resolvePickId(picked) {
const unwrap = (id) => {
if (id === null || id === undefined) return undefined;
if (typeof id === 'object') {
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string)
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number')
return id.mmsi;
// AIS vessel record or Cesium Entity: both are keyed by their `.id`.
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
return undefined;
}
+8 -6
View File
@@ -33,10 +33,12 @@ test('resolvePickId: numeric pick id (satellite NORAD numbers) → String', () =
assert.equal(resolvePickId({ id: undefined, primitive: { id: 43013 } }), '43013');
});
test('resolvePickId: AIS vessel record object → its mmsi', () => {
const record = { mmsi: '367123450', name: 'EVER GIVEN', billboard: {} };
test('resolvePickId: AIS vessel record object → its id, not its MMSI', () => {
const record = { id: '367123450', mmsi: '367123450', name: 'EVER GIVEN', billboard: {} };
assert.equal(resolvePickId({ id: record }), '367123450');
assert.equal(resolvePickId({ primitive: { id: record } }), '367123450');
const shared = { id: 'hull-b', mmsi: '367123450', name: 'EVER GIVEN' };
assert.equal(resolvePickId({ id: shared }), 'hull-b');
});
test('resolvePickId: Cesium-Entity-like object → its string id', () => {
@@ -48,7 +50,7 @@ test('resolvePickId: unresolvable picks → null', () => {
assert.equal(resolvePickId(null), null);
assert.equal(resolvePickId(undefined), null);
assert.equal(resolvePickId({}), null);
assert.equal(resolvePickId({ id: {} }), null); // object with no mmsi/id
assert.equal(resolvePickId({ id: {} }), null); // object with no id
assert.equal(resolvePickId({ primitive: {} }), null);
});
@@ -67,8 +69,8 @@ test('ownership: sibling layers recognize each other via String-coerced ids', ()
try {
// Flights asking about a satellite pick (raw numeric id → String upstream)
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: 25544 })), true);
// Flights asking about a vessel pick (record object → mmsi)
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: { mmsi: '367123450' } })), true);
// Flights asking about a vessel pick (record object → id)
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: { id: '367123450', mmsi: '367123450' } })), true);
// A layer never owns its own pick via the sibling scan
assert.equal(isOwnedByOtherLayer('satellites', '25544'), false);
// Unknown ids belong to nobody
@@ -147,7 +149,7 @@ test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CS
test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => {
primaryPointer(t, true);
contactOwners(t);
for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) {
for (const first of [{ id: 'military' }, { id: { id: '123456789', mmsi: '123456789' } }]) {
const scene = {
pick: () => mapTile,
drillPick: () => [mapTile, first, { id: 'civil' }],
+2 -2
View File
@@ -64,8 +64,8 @@ function contextCarrierRecord(picked, dataManager) {
function fromRecord(record) {
const layerId = record.layerId || null;
const id =
layerId === 'ais-live-vessels' && record.properties?.mmsi
? String(record.properties.mmsi)
layerId === 'ais-live-vessels' && record.properties?.vesselId
? String(record.properties.vesselId)
: String(record.id);
return {
layerId,
+1 -1
View File
@@ -155,7 +155,7 @@ export function applyVesselOverlayPolicy(
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
// Only MMSI-keyed cards can resolve back to one actionable vessel.
// Only keyed cards can resolve back to one actionable vessel.
interactive: card?.actionable === true,
};
}
+13 -3
View File
@@ -2676,8 +2676,6 @@
"Shared flight is unavailable": "الرحلة المشتركة غير متاحة",
"Shared military flight is unavailable": "الرحلة العسكرية المشتركة غير متاحة",
"Shared satellite is unavailable": "القمر الصناعي المشترك غير متاح",
"{id} isn't reporting now — showing where it was last seen": "{id}: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
"{id} isn't reporting now — showing where it was at {time}": "{id}: لا بيانات واردة الآن — يُعرض الموضع عند {time}",
"This share link can't be opened": "لا يمكن فتح رابط المشاركة هذا",
"Shared vessel is unavailable": "السفينة المشتركة غير متاحة",
"subject": "الهدف",
@@ -3778,5 +3776,17 @@
"Play speed": "سرعة التشغيل",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل",
"1 min/s": "دقيقة/ث",
"Photo © {photographer}": "صورة © {photographer}"
"Photo © {photographer}": "صورة © {photographer}",
"The shared flight isn't reporting now — showing where it was last seen": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
"The shared flight isn't reporting now — showing where it was at {time}": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "الرحلة العسكرية المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
"The shared military flight isn't reporting now — showing where it was at {time}": "الرحلة العسكرية المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
"The shared vessel isn't reporting now — showing where it was last seen": "السفينة المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
"The shared vessel isn't reporting now — showing where it was at {time}": "السفينة المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
"The shared satellite isn't reporting now — showing where it was last seen": "القمر الصناعي المشترك: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
"The shared satellite isn't reporting now — showing where it was at {time}": "القمر الصناعي المشترك: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "معرّف UUID للسفينة: {id}"
}
+13 -3
View File
@@ -2528,8 +2528,6 @@
"Shared flight is unavailable": "Geteilter Flug ist nicht verfügbar",
"Shared military flight is unavailable": "Geteilter Militärflug ist nicht verfügbar",
"Shared satellite is unavailable": "Geteilter Satellit ist nicht verfügbar",
"{id} isn't reporting now — showing where it was last seen": "{id} sendet gerade nicht – letzte bekannte Position wird gezeigt",
"{id} isn't reporting now — showing where it was at {time}": "{id} sendet gerade nicht – Position um {time} wird gezeigt",
"This share link can't be opened": "Dieser Freigabelink kann nicht geöffnet werden",
"Shared vessel is unavailable": "Geteiltes Schiff ist nicht verfügbar",
"subject": "Objekt",
@@ -3454,5 +3452,17 @@
"Play speed": "Wiedergabegeschwindigkeit",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles",
"1 min/s": "1 Min./s",
"Photo © {photographer}": "Foto © {photographer}"
"Photo © {photographer}": "Foto © {photographer}",
"The shared flight isn't reporting now — showing where it was last seen": "Der geteilte Flug sendet gerade nicht – letzte bekannte Position wird gezeigt",
"The shared flight isn't reporting now — showing where it was at {time}": "Der geteilte Flug sendet gerade nicht – Position um {time} wird gezeigt",
"The shared military flight isn't reporting now — showing where it was last seen": "Der geteilte Militärflug sendet gerade nicht – letzte bekannte Position wird gezeigt",
"The shared military flight isn't reporting now — showing where it was at {time}": "Der geteilte Militärflug sendet gerade nicht – Position um {time} wird gezeigt",
"The shared vessel isn't reporting now — showing where it was last seen": "Das geteilte Schiff sendet gerade nicht – letzte bekannte Position wird gezeigt",
"The shared vessel isn't reporting now — showing where it was at {time}": "Das geteilte Schiff sendet gerade nicht – Position um {time} wird gezeigt",
"The shared satellite isn't reporting now — showing where it was last seen": "Der geteilte Satellit sendet gerade nicht – letzte bekannte Position wird gezeigt",
"The shared satellite isn't reporting now — showing where it was at {time}": "Der geteilte Satellit sendet gerade nicht – Position um {time} wird gezeigt",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "Schiffs-UUID: {id}"
}
+13 -3
View File
@@ -427,8 +427,6 @@
"Link copied!": "¡Enlace copiado!",
"Link shared": "Enlace compartido",
"This share link can't be opened": "Este enlace compartido no se puede abrir",
"{id} isn't reporting now — showing where it was at {time}": "{id} no está transmitiendo ahora; se muestra dónde estaba a las {time}",
"{id} isn't reporting now — showing where it was last seen": "{id} no está transmitiendo ahora; se muestra dónde se vio por última vez",
"Shared vessel is unavailable": "El buque compartido no está disponible",
"Copy failed": "No se pudo copiar",
"Location not found": "Ubicación no encontrada",
@@ -3535,5 +3533,17 @@
"Play speed": "Velocidad de reproducción",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo",
"1 min/s": "1 min/s",
"Photo © {photographer}": "Foto © {photographer}"
"Photo © {photographer}": "Foto © {photographer}",
"The shared flight isn't reporting now — showing where it was last seen": "El vuelo compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
"The shared flight isn't reporting now — showing where it was at {time}": "El vuelo compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
"The shared military flight isn't reporting now — showing where it was at {time}": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
"The shared vessel isn't reporting now — showing where it was last seen": "El buque compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
"The shared vessel isn't reporting now — showing where it was at {time}": "El buque compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
"The shared satellite isn't reporting now — showing where it was last seen": "El satélite compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
"The shared satellite isn't reporting now — showing where it was at {time}": "El satélite compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID del buque: {id}"
}
+13 -3
View File
@@ -2287,8 +2287,6 @@
"Shared {subject} follow expired": "Suivi partagé expiré : {subject}",
"Shared {subject} could not be restored — feed unavailable": "Suivi partagé non restauré : {subject} — flux indisponible",
"Shared {subject} is unavailable": "Partage indisponible : {subject}",
"{id} isn't reporting now — showing where it was last seen": "{id} n'émet pas actuellement — affichage de sa dernière position connue",
"{id} isn't reporting now — showing where it was at {time}": "{id} n'émet pas actuellement — affichage de sa position à {time}",
"This share link can't be opened": "Ce lien de partage ne peut pas être ouvert",
"Shared vessel is unavailable": "Le navire partagé est indisponible",
"ACQUIRING": "ACQUISITION",
@@ -3535,5 +3533,17 @@
"Play speed": "Vitesse de lecture",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir",
"1 min/s": "1 min/s",
"Photo © {photographer}": "Photo © {photographer}"
"Photo © {photographer}": "Photo © {photographer}",
"The shared flight isn't reporting now — showing where it was last seen": "Le vol partagé n'émet pas actuellement — affichage de sa dernière position connue",
"The shared flight isn't reporting now — showing where it was at {time}": "Le vol partagé n'émet pas actuellement — affichage de sa position à {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "Le vol militaire partagé n'émet pas actuellement — affichage de sa dernière position connue",
"The shared military flight isn't reporting now — showing where it was at {time}": "Le vol militaire partagé n'émet pas actuellement — affichage de sa position à {time}",
"The shared vessel isn't reporting now — showing where it was last seen": "Le navire partagé n'émet pas actuellement — affichage de sa dernière position connue",
"The shared vessel isn't reporting now — showing where it was at {time}": "Le navire partagé n'émet pas actuellement — affichage de sa position à {time}",
"The shared satellite isn't reporting now — showing where it was last seen": "Le satellite partagé n'émet pas actuellement — affichage de sa dernière position connue",
"The shared satellite isn't reporting now — showing where it was at {time}": "Le satellite partagé n'émet pas actuellement — affichage de sa position à {time}",
"ICAO: {id}": "ICAO : {id}",
"NORAD: {id}": "NORAD : {id}",
"MMSI: {id}": "MMSI : {id}",
"Vessel UUID: {id}": "UUID du navire : {id}"
}
+13 -3
View File
@@ -2119,8 +2119,6 @@
"Shared {subject} follow expired": "עקיבת {subject} המשותף פגה",
"Shared {subject} could not be restored — feed unavailable": "לא ניתן לשחזר את {subject} המשותף — הפיד אינו זמין",
"Shared {subject} is unavailable": "{subject} המשותף אינו זמין",
"{id} isn't reporting now — showing where it was last seen": "{id} לא מדווח עכשיו — מציג היכן נראה לאחרונה",
"{id} isn't reporting now — showing where it was at {time}": "{id} לא מדווח עכשיו — מציג היכן היה ב-{time}",
"This share link can't be opened": "לא ניתן לפתוח את קישור השיתוף הזה",
"Shared vessel is unavailable": "האונייה המשותפת אינה זמינה",
"ACQUIRING": "מאתר",
@@ -3535,5 +3533,17 @@
"other": "{count} ספינות טרופות"
},
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (מימי אירופה) · ויקינתונים (כל העולם)",
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ספינות טרופות אירופיות בעלות ערך מורשת מגיעות מ־EMODnet Human Activities; ספינות טרופות בולטות מכל העולם מגיעות מוויקינתונים. הצבע מראה מתי הספינה טבעה; הסמן גדל ככל שמספר ההרוגים עולה. לחץ על סמן כדי לפתוח את הכרטיס שלו."
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ספינות טרופות אירופיות בעלות ערך מורשת מגיעות מ־EMODnet Human Activities; ספינות טרופות בולטות מכל העולם מגיעות מוויקינתונים. הצבע מראה מתי הספינה טבעה; הסמן גדל ככל שמספר ההרוגים עולה. לחץ על סמן כדי לפתוח את הכרטיס שלו.",
"The shared flight isn't reporting now — showing where it was last seen": "הטיסה המשותפת לא מדווחת עכשיו — מוצג המיקום האחרון הידוע",
"The shared flight isn't reporting now — showing where it was at {time}": "הטיסה המשותפת לא מדווחת עכשיו — מוצג המיקום בשעה {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "הטיסה הצבאית המשותפת לא מדווחת עכשיו — מוצג המיקום האחרון הידוע",
"The shared military flight isn't reporting now — showing where it was at {time}": "הטיסה הצבאית המשותפת לא מדווחת עכשיו — מוצג המיקום בשעה {time}",
"The shared vessel isn't reporting now — showing where it was last seen": "כלי השיט המשותף לא מדווח עכשיו — מוצג המיקום האחרון הידוע",
"The shared vessel isn't reporting now — showing where it was at {time}": "כלי השיט המשותף לא מדווח עכשיו — מוצג המיקום בשעה {time}",
"The shared satellite isn't reporting now — showing where it was last seen": "הלוויין המשותף לא מדווח עכשיו — מוצג המיקום האחרון הידוע",
"The shared satellite isn't reporting now — showing where it was at {time}": "הלוויין המשותף לא מדווח עכשיו — מוצג המיקום בשעה {time}",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID של כלי השיט: {id}"
}
+13 -3
View File
@@ -2287,8 +2287,6 @@
"Shared {subject} follow expired": "Il tracciamento condiviso di {subject} è scaduto",
"Shared {subject} could not be restored — feed unavailable": "Impossibile ripristinare la condivisione di {subject}: feed non disponibile",
"Shared {subject} is unavailable": "La condivisione di {subject} non è disponibile",
"{id} isn't reporting now — showing where it was last seen": "{id} non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
"{id} isn't reporting now — showing where it was at {time}": "{id} non sta trasmettendo ora: viene mostrata l'ultima posizione nota ({time})",
"This share link can't be opened": "Impossibile aprire questo link di condivisione",
"Shared vessel is unavailable": "La nave condivisa non è disponibile",
"ACQUIRING": "ACQUISIZIONE",
@@ -3535,5 +3533,17 @@
"Play speed": "Velocità di riproduzione",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto",
"1 min/s": "1 min/s",
"Photo © {photographer}": "Foto © {photographer}"
"Photo © {photographer}": "Foto © {photographer}",
"The shared flight isn't reporting now — showing where it was last seen": "Il volo condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
"The shared flight isn't reporting now — showing where it was at {time}": "Il volo condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
"The shared military flight isn't reporting now — showing where it was at {time}": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}",
"The shared vessel isn't reporting now — showing where it was last seen": "La nave condivisa non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
"The shared vessel isn't reporting now — showing where it was at {time}": "La nave condivisa non sta trasmettendo ora: viene mostrata la posizione alle {time}",
"The shared satellite isn't reporting now — showing where it was last seen": "Il satellite condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
"The shared satellite isn't reporting now — showing where it was at {time}": "Il satellite condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID della nave: {id}"
}
+13 -3
View File
@@ -2209,8 +2209,6 @@
"Shared {subject} follow expired": "共有された{subject}の追従が期限切れになりました",
"Shared {subject} could not be restored — feed unavailable": "共有された{subject}を復元できませんでした:フィードを利用できません",
"Shared {subject} is unavailable": "共有された{subject}は利用できません",
"{id} isn't reporting now — showing where it was last seen": "{id}は現在報告していません。最後に確認された位置を表示しています",
"{id} isn't reporting now — showing where it was at {time}": "{id}は現在報告していません。{time}時点の位置を表示しています",
"This share link can't be opened": "この共有リンクは開けません",
"Shared vessel is unavailable": "共有された船舶は利用できません",
"ACQUIRING": "取得中",
@@ -3373,5 +3371,17 @@
"Play speed": "再生速度",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示",
"1 min/s": "1分/秒",
"Photo © {photographer}": "写真 © {photographer}"
"Photo © {photographer}": "写真 © {photographer}",
"The shared flight isn't reporting now — showing where it was last seen": "共有されたフライトは現在報告していません。最後に確認された位置を表示しています",
"The shared flight isn't reporting now — showing where it was at {time}": "共有されたフライトは現在報告していません。{time}時点の位置を表示しています",
"The shared military flight isn't reporting now — showing where it was last seen": "共有された軍用機は現在報告していません。最後に確認された位置を表示しています",
"The shared military flight isn't reporting now — showing where it was at {time}": "共有された軍用機は現在報告していません。{time}時点の位置を表示しています",
"The shared vessel isn't reporting now — showing where it was last seen": "共有された船舶は現在報告していません。最後に確認された位置を表示しています",
"The shared vessel isn't reporting now — showing where it was at {time}": "共有された船舶は現在報告していません。{time}時点の位置を表示しています",
"The shared satellite isn't reporting now — showing where it was last seen": "共有された衛星は現在報告していません。最後に確認された位置を表示しています",
"The shared satellite isn't reporting now — showing where it was at {time}": "共有された衛星は現在報告していません。{time}時点の位置を表示しています",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "船舶UUID: {id}"
}
+13 -3
View File
@@ -2287,8 +2287,6 @@
"Shared {subject} follow expired": "O acompanhamento compartilhado de {subject} expirou",
"Shared {subject} could not be restored — feed unavailable": "Não foi possível restaurar o compartilhamento de {subject} — feed indisponível",
"Shared {subject} is unavailable": "O compartilhamento de {subject} está indisponível",
"{id} isn't reporting now — showing where it was last seen": "{id} não está transmitindo agora — mostrando onde foi visto pela última vez",
"{id} isn't reporting now — showing where it was at {time}": "{id} não está transmitindo agora — mostrando onde estava em {time}",
"This share link can't be opened": "Não é possível abrir este link de compartilhamento",
"Shared vessel is unavailable": "A embarcação compartilhada está indisponível",
"ACQUIRING": "ADQUIRINDO",
@@ -3535,5 +3533,17 @@
"Play speed": "Velocidade de reprodução",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo",
"1 min/s": "1 min/s",
"Photo © {photographer}": "Foto © {photographer}"
"Photo © {photographer}": "Foto © {photographer}",
"The shared flight isn't reporting now — showing where it was last seen": "O voo compartilhado não está transmitindo agora — mostrando a última posição conhecida",
"The shared flight isn't reporting now — showing where it was at {time}": "O voo compartilhado não está transmitindo agora — mostrando onde estava às {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "O voo militar compartilhado não está transmitindo agora — mostrando a última posição conhecida",
"The shared military flight isn't reporting now — showing where it was at {time}": "O voo militar compartilhado não está transmitindo agora — mostrando onde estava às {time}",
"The shared vessel isn't reporting now — showing where it was last seen": "A embarcação compartilhada não está transmitindo agora — mostrando a última posição conhecida",
"The shared vessel isn't reporting now — showing where it was at {time}": "A embarcação compartilhada não está transmitindo agora — mostrando onde estava às {time}",
"The shared satellite isn't reporting now — showing where it was last seen": "O satélite compartilhado não está transmitindo agora — mostrando a última posição conhecida",
"The shared satellite isn't reporting now — showing where it was at {time}": "O satélite compartilhado não está transmitindo agora — mostrando onde estava às {time}",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID da embarcação: {id}"
}
+13 -3
View File
@@ -425,8 +425,6 @@
"Link copied!": "Ссылка скопирована!",
"Link shared": "Ссылка отправлена",
"This share link can't be opened": "Не удаётся открыть эту ссылку",
"{id} isn't reporting now — showing where it was at {time}": "{id} сейчас не передаёт данные — показано последнее известное место ({time})",
"{id} isn't reporting now — showing where it was last seen": "{id} сейчас не передаёт данные — показано последнее известное место",
"Shared vessel is unavailable": "Судно по ссылке недоступно",
"Copy failed": "Не удалось скопировать",
"VISUAL PRESETS": "ПРЕСЕТЫ",
@@ -3616,5 +3614,17 @@
"Play speed": "Скорость воспроизведения",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё",
"1 min/s": "1 мин/с",
"Photo © {photographer}": "Фото © {photographer}"
"Photo © {photographer}": "Фото © {photographer}",
"The shared flight isn't reporting now — showing where it was last seen": "Общий рейс сейчас не передаёт данные — показано последнее известное место",
"The shared flight isn't reporting now — showing where it was at {time}": "Общий рейс сейчас не передаёт данные — показано место в {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "Общий военный рейс сейчас не передаёт данные — показано последнее известное место",
"The shared military flight isn't reporting now — showing where it was at {time}": "Общий военный рейс сейчас не передаёт данные — показано место в {time}",
"The shared vessel isn't reporting now — showing where it was last seen": "Общее судно сейчас не передаёт данные — показано последнее известное место",
"The shared vessel isn't reporting now — showing where it was at {time}": "Общее судно сейчас не передаёт данные — показано место в {time}",
"The shared satellite isn't reporting now — showing where it was last seen": "Общий спутник сейчас не передаёт данные — показано последнее известное место",
"The shared satellite isn't reporting now — showing where it was at {time}": "Общий спутник сейчас не передаёт данные — показано место в {time}",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID судна: {id}"
}
+13 -3
View File
@@ -2325,8 +2325,6 @@
"Shared {subject} follow expired": "Посилання на стеження застаріло: {subject}",
"Shared {subject} could not be restored — feed unavailable": "Не вдалося відновити за посиланням: {subject} — потік даних недоступний",
"Shared {subject} is unavailable": "Недоступно за посиланням: {subject}",
"{id} isn't reporting now — showing where it was last seen": "{id} зараз не передає даних — показано останнє відоме місце",
"{id} isn't reporting now — showing where it was at {time}": "{id} зараз не передає даних — показано останнє відоме місце ({time})",
"This share link can't be opened": "Не вдається відкрити це посилання",
"Shared vessel is unavailable": "Судно за посиланням недоступне",
"ACQUIRING": "ОТРИМАННЯ",
@@ -3616,5 +3614,17 @@
"Play speed": "Швидкість відтворення",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все",
"1 min/s": "1 хв/с",
"Photo © {photographer}": "Фото © {photographer}"
"Photo © {photographer}": "Фото © {photographer}",
"The shared flight isn't reporting now — showing where it was last seen": "Спільний рейс зараз не передає даних — показано останнє відоме місце",
"The shared flight isn't reporting now — showing where it was at {time}": "Спільний рейс зараз не передає даних — показано місце о {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "Спільний військовий рейс зараз не передає даних — показано останнє відоме місце",
"The shared military flight isn't reporting now — showing where it was at {time}": "Спільний військовий рейс зараз не передає даних — показано місце о {time}",
"The shared vessel isn't reporting now — showing where it was last seen": "Спільне судно зараз не передає даних — показано останнє відоме місце",
"The shared vessel isn't reporting now — showing where it was at {time}": "Спільне судно зараз не передає даних — показано місце о {time}",
"The shared satellite isn't reporting now — showing where it was last seen": "Спільний супутник зараз не передає даних — показано останнє відоме місце",
"The shared satellite isn't reporting now — showing where it was at {time}": "Спільний супутник зараз не передає даних — показано місце о {time}",
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID судна: {id}"
}
+13 -3
View File
@@ -1605,8 +1605,6 @@
"Link copied!": "链接已复制!",
"Link shared": "链接已分享",
"This share link can't be opened": "无法打开此分享链接",
"{id} isn't reporting now — showing where it was at {time}": "{id} 目前没有发送信号——显示其在 {time} 的位置",
"{id} isn't reporting now — showing where it was last seen": "{id} 目前没有发送信号——显示其最后出现的位置",
"Shared vessel is unavailable": "分享的船只不可用",
"Copy failed": "复制失败",
"Return map to straight-down view": "将地图恢复为垂直俯视",
@@ -3373,5 +3371,17 @@
"Play speed": "播放速度",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部",
"1 min/s": "1 分钟/秒",
"Photo © {photographer}": "照片 © {photographer}"
"Photo © {photographer}": "照片 © {photographer}",
"The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前没有发送信号——显示其最后出现的位置",
"The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前没有发送信号——显示其在 {time} 的位置",
"The shared military flight isn't reporting now — showing where it was last seen": "共享的军用航班目前没有发送信号——显示其最后出现的位置",
"The shared military flight isn't reporting now — showing where it was at {time}": "共享的军用航班目前没有发送信号——显示其在 {time} 的位置",
"The shared vessel isn't reporting now — showing where it was last seen": "共享的船舶目前没有发送信号——显示其最后出现的位置",
"The shared vessel isn't reporting now — showing where it was at {time}": "共享的船舶目前没有发送信号——显示其在 {time} 的位置",
"The shared satellite isn't reporting now — showing where it was last seen": "共享的卫星目前没有发送信号——显示其最后出现的位置",
"The shared satellite isn't reporting now — showing where it was at {time}": "共享的卫星目前没有发送信号——显示其在 {time} 的位置",
"ICAO: {id}": "ICAO:{id}",
"NORAD: {id}": "NORAD:{id}",
"MMSI: {id}": "MMSI:{id}",
"Vessel UUID: {id}": "船舶 UUID:{id}"
}
+13 -3
View File
@@ -2983,8 +2983,6 @@
"Take the exit {direction}": "從出口{direction}駛出",
"Link shared": "鏈接已分享",
"This share link can't be opened": "無法打開此分享鏈接",
"{id} isn't reporting now — showing where it was at {time}": "{id} 目前沒有發送信號——顯示其在 {time} 的位置",
"{id} isn't reporting now — showing where it was last seen": "{id} 目前沒有發送信號——顯示其最後出現的位置",
"Shared vessel is unavailable": "分享的船隻不可用",
"Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。",
"Press Enter to try again.": "按 Enter 重試。",
@@ -3373,5 +3371,17 @@
"Play speed": "播放速度",
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部",
"1 min/s": "1 分鐘/秒",
"Photo © {photographer}": "照片 © {photographer}"
"Photo © {photographer}": "照片 © {photographer}",
"The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前沒有發送信號——顯示其最後出現的位置",
"The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前沒有發送信號——顯示其在 {time} 的位置",
"The shared military flight isn't reporting now — showing where it was last seen": "共享的軍用航班目前沒有發送信號——顯示其最後出現的位置",
"The shared military flight isn't reporting now — showing where it was at {time}": "共享的軍用航班目前沒有發送信號——顯示其在 {time} 的位置",
"The shared vessel isn't reporting now — showing where it was last seen": "共享的船舶目前沒有發送信號——顯示其最後出現的位置",
"The shared vessel isn't reporting now — showing where it was at {time}": "共享的船舶目前沒有發送信號——顯示其在 {time} 的位置",
"The shared satellite isn't reporting now — showing where it was last seen": "共享的衛星目前沒有發送信號——顯示其最後出現的位置",
"The shared satellite isn't reporting now — showing where it was at {time}": "共享的衛星目前沒有發送信號——顯示其在 {time} 的位置",
"ICAO: {id}": "ICAO:{id}",
"NORAD: {id}": "NORAD:{id}",
"MMSI: {id}": "MMSI:{id}",
"Vessel UUID: {id}": "船舶 UUID:{id}"
}
+36 -9
View File
@@ -44,7 +44,10 @@
"{direction} {angle}°",
"{direction} · {elevation}",
"{source} HTTP {status}",
"▲ {frp} MW"
"▲ {frp} MW",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"de": [
"1090 MHz",
@@ -238,7 +241,10 @@
"ΔN",
"Ⅱ PAUSE",
"≥ {value} MW",
"▲ {frp} MW"
"▲ {frp} MW",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"es": [
"1 min/s",
@@ -395,7 +401,10 @@
"Reptiles",
"acartia.io ↗",
"iNaturalist ↗",
"iNaturalist"
"iNaturalist",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"fr": [
"1 min/s",
@@ -810,7 +819,10 @@
"acartia.io ↗",
"iNaturalist ↗",
"iNaturalist",
"SATELLITE"
"SATELLITE",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"ja": [
"1090 MHz",
@@ -873,7 +885,10 @@
"ΔE",
"ΔN",
"≥ {value} MW",
"▲ {frp} MW"
"▲ {frp} MW",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"pt": [
"1 min/s",
@@ -1031,7 +1046,10 @@
"CC BY 4.0",
"acartia.io ↗",
"iNaturalist ↗",
"iNaturalist"
"iNaturalist",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"ru": [
"1900–1945",
@@ -1064,7 +1082,10 @@
"iNaturalist ↗",
"{direction} · {elevation}",
"{direction} {angle}°",
"{division}, {country}"
"{division}, {country}",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"uk": [
"1900–1945",
@@ -1092,7 +1113,10 @@
"iNaturalist ↗",
"{direction} · {elevation}",
"{direction} {angle}°",
"{division}, {country}"
"{division}, {country}",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
],
"zh-CN": [
"1090 MHz",
@@ -1331,6 +1355,9 @@
"TLE",
"{value} KM",
"{value} KM/S",
"Ironbow"
"Ironbow",
"ICAO: {id}",
"MMSI: {id}",
"NORAD: {id}"
]
}
+1 -1
View File
@@ -150,7 +150,7 @@ export function createHistory({ state: layerState, services, parts, source }) {
return (
items.find(
(item) =>
String(item?.icao24 || item?.mmsi || item?.id) === String(subject.id),
String(item?.icao24 || item?.id || item?.mmsi) === String(subject.id),
) || subject
);
}
+1 -1
View File
@@ -117,7 +117,7 @@ export function createPanel({ state: layerState, parts }) {
.slice(page, page + AWARENESS_PAGE_SIZE)
.map((item, index) => {
const label = formatAwarenessLabel(item);
const targetId = item.icao24 || item.mmsi || item.id;
const targetId = item.icao24 || item.id || item.mmsi;
const distance = presentAwarenessDistance(item.distanceM);
if (!targetId)
return {
+2 -2
View File
@@ -85,7 +85,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
.filter(
(item) =>
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'mmsi'),
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'id'),
);
const coverage = installationsState.stats?.coverage;
const installationRadius =
@@ -519,7 +519,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
return {
layerId: record.layerId,
id: record.properties?.mmsi || record.id,
id: record.properties?.vesselId || record.id,
label: record.label || record.id,
memberNames: record.properties?.memberNames || [],
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
+8 -6
View File
@@ -60,7 +60,7 @@ export function createCards({
parts.push(`${roundHeadingDegrees(direction)}°`);
return {
id: vesselOverlayEntryId(record),
actionable: Boolean(record?.mmsi),
actionable: Boolean(record?.id),
position:
components.rendering.getVisual(record).billboard?.position ||
components.rendering.getVisual(record).position,
@@ -101,7 +101,7 @@ export function createCards({
);
return {
id: vesselOverlayEntryId(record),
actionable: Boolean(record?.mmsi),
actionable: Boolean(record?.id),
position:
components.rendering.getVisual(record).billboard?.position ||
components.rendering.getVisual(record).position,
@@ -114,11 +114,11 @@ export function createCards({
};
}
/** Stable overlay identity for MMSI-keyed and source-retained unkeyed rows. */
/** Stable overlay identity for keyed and source-retained unkeyed rows. */
function vesselOverlayEntryId(record) {
const mmsi = String(record?.mmsi || '').trim();
if (mmsi) return `vessel:${mmsi}`;
const id = String(record?.id || '').trim();
if (id) return `vessel:${id}`;
const name = String(record?.name || 'VESSEL').trim() || 'VESSEL';
const lat = Number.isFinite(record?.lat) ? record.lat.toFixed(5) : 'x';
const lon = Number.isFinite(record?.lon) ? record.lon.toFixed(5) : 'x';
@@ -161,7 +161,9 @@ export function createCards({
function displayVesselName(record) {
const name = String(record.name || '').trim();
if (name && name !== 'VESSEL' && name !== record.mmsi) return name;
// A source may name a vessel by its id when it has no name.
if (name && name !== 'VESSEL' && name !== record.mmsi && name !== record.id)
return name;
return record.mmsi ? `MMSI ${record.mmsi}` : t('VESSEL');
}
+5 -5
View File
@@ -37,19 +37,19 @@ function store() {
test('rows at another time stand in for every vessel, selected or not', () => {
const probe = store();
probe.reconcile([row('111'), row('222')]);
const selectedRecord = probe.records.byMmsi.get('222');
const selectedRecord = probe.records.byId.get('222');
probe.reconcile([row('111', { last_position_epoch: 900 })], {
complete: false,
selectedRecord,
replace: true,
});
assert.deepEqual([...probe.records.byMmsi.keys()], ['111']);
assert.deepEqual([...probe.records.byId.keys()], ['111']);
assert.deepEqual(probe.events.slice(-3), [
['update', '111'],
['remove', '222', true],
['removed', '222'],
]);
assert.equal(probe.records.byMmsi.get('111').motion, null);
assert.equal(probe.records.byId.get('111').motion, null);
});
test('following the clock, a vessel keeps where its fix moved from', () => {
@@ -58,7 +58,7 @@ test('following the clock, a vessel keeps where its fix moved from', () => {
probe.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })], {
follow: true,
});
const record = probe.records.byMmsi.get('111');
const record = probe.records.byId.get('111');
assert.deepEqual(record.motion, {
fromLat: 51.93,
fromLon: 4.05,
@@ -75,7 +75,7 @@ test('following the clock, a vessel keeps where its fix moved from', () => {
live.reconcile([row('111')]);
live.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })]);
assert.equal(
Object.hasOwn(live.records.byMmsi.get('111'), 'motion'),
Object.hasOwn(live.records.byId.get('111'), 'motion'),
false,
'live records are as before',
);
+4 -4
View File
@@ -99,7 +99,7 @@ function finiteOrNull(value) {
* @returns {object|null} Subject info, or null without a usable fix.
*/
export function vesselCockpitInfo(record) {
if (!record?.mmsi) return null;
if (!record?.id) return null;
const latitude = Number(record.lat);
const longitude = Number(record.lon);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
@@ -111,9 +111,9 @@ export function vesselCockpitInfo(record) {
return {
kind: 'vessel',
layerId: VESSEL_COCKPIT_LAYER_ID,
id: String(record.mmsi),
mmsi: String(record.mmsi),
name: String(record.name || record.mmsi),
id: String(record.id),
mmsi: String(record.mmsi || ''),
name: String(record.name || record.mmsi || record.id),
type: String(record.type || ''),
typeLabel: displayVesselType(record.type),
destination: String(record.destination || '').trim(),
+23 -3
View File
@@ -43,6 +43,7 @@ test('an unknown or missing type has no eye height of its own', () => {
test('vessel info faces the true heading and moves along course over ground', () => {
const info = vesselCockpitInfo({
id: '211000000',
mmsi: '211000000',
name: 'NORDIC STAR',
type: '70',
@@ -58,6 +59,7 @@ test('vessel info faces the true heading and moves along course over ground', ()
assert.equal(info.kind, 'vessel');
assert.equal(info.layerId, VESSEL_COCKPIT_LAYER_ID);
assert.equal(info.id, '211000000');
assert.equal(info.mmsi, '211000000');
assert.equal(info.typeLabel, 'CARGO');
assert.equal(info.track, 90);
assert.equal(info.motionCourse, 95);
@@ -70,7 +72,7 @@ test('vessel info faces the true heading and moves along course over ground', ()
});
test('a missing heading or course falls back to the other', () => {
const base = { mmsi: '1', lat: 1, lon: 1, speed: 3 };
const base = { id: '1', mmsi: '1', lat: 1, lon: 1, speed: 3 };
assert.equal(vesselCockpitInfo({ ...base, course: 40 }).track, 40);
assert.equal(vesselCockpitInfo({ ...base, heading: 50 }).motionCourse, 50);
const neither = vesselCockpitInfo(base);
@@ -79,7 +81,7 @@ test('a missing heading or course falls back to the other', () => {
});
test('the subject says whether its track is the heading or the course', () => {
const base = { mmsi: '1', lat: 1, lon: 1, speed: 3 };
const base = { id: '1', mmsi: '1', lat: 1, lon: 1, speed: 3 };
assert.equal(
vesselCockpitInfo({ ...base, heading: 50, course: 40 }).trackSource,
'heading',
@@ -99,11 +101,12 @@ test('the subject says whether its track is the heading or the course', () => {
test('a vessel without identity or position is not a subject', () => {
assert.equal(vesselCockpitInfo(null), null);
assert.equal(vesselCockpitInfo({ lat: 1, lon: 1 }), null);
assert.equal(vesselCockpitInfo({ mmsi: '1', lat: 'x', lon: 1 }), null);
assert.equal(vesselCockpitInfo({ id: '1', mmsi: '1', lat: 'x', lon: 1 }), null);
});
test('a vessel missing from recent refreshes reads as stale', () => {
const info = vesselCockpitInfo({
id: '1',
mmsi: '1',
lat: 1,
lon: 1,
@@ -111,3 +114,20 @@ test('a vessel missing from recent refreshes reads as stale', () => {
});
assert.equal(info.stale, true);
});
test('a vessel is the subject by its id; an MMSI is only shown when known', () => {
const info = vesselCockpitInfo({
id: 'b6a7c1e2-provider-id',
mmsi: '',
name: 'NORDIC STAR',
lat: 54.5,
lon: 10.2,
});
assert.equal(info.id, 'b6a7c1e2-provider-id');
assert.equal(info.mmsi, '');
assert.equal(
vesselCockpitInfo({ mmsi: '211000000', lat: 54.5, lon: 10.2 }),
null,
'a record with no id is unkeyed and cannot be ridden',
);
});
+1 -1
View File
@@ -47,7 +47,7 @@ export function createEvidence({
)
: null;
return {
id: record.mmsi,
id: record.id,
show: bb?.show === true,
alpha: bb?.color?.alpha ?? null,
x: screen?.x ?? null,
+3 -1
View File
@@ -201,9 +201,11 @@ export function createIngestion({
return { reconciled: true, ...snapshot };
}
/** The layer's row for a source record: keyed by its id, MMSI shown when known. */
function vesselDisplayRow(record) {
return {
mmsi: record.id,
id: record.id,
mmsi: String(record.mmsi ?? '').trim(),
reference: record.reference,
lat: record.latitude,
lon: record.longitude,
+4 -4
View File
@@ -116,7 +116,7 @@ export function createLifecycle({
state.feed.abort = null;
state.billboardCollection = null;
state.records.all = [];
state.records.byMmsi = new Map();
state.records.byId = new Map();
state.records.unkeyed = [];
state.clickHandler = null;
state.keyTarget = null;
@@ -133,7 +133,7 @@ export function createLifecycle({
state.selectedRecord = null;
state.trail = null;
state.trailPositions = [];
state.trailMmsi = null;
state.trailId = null;
state.trailBackfillToken = 0;
}
const methods = {
@@ -188,9 +188,9 @@ export function createLifecycle({
});
}
// Pick-ownership (H2): vessel picks carry the record OBJECT as their id;
// the registry resolver reduces it to the record's mmsi (a string key).
// the registry resolver reduces it to the record's id (a string key).
registerPickOwner('ais-live-vessels', (pickedId) =>
state.records.byMmsi.has(pickedId),
state.records.byId.has(pickedId),
);
restoreSpriteOrderOnEnable('ais', activeViewer);
// Vessels are asked for by view, so ask again when the view moves away.
+96 -2
View File
@@ -98,7 +98,7 @@ test('vessel construction is inert and each instance owns its records, icon cach
const two = createVesselState({ source, services: services() });
for (const key of ['state', 'shipIconCache', '_scratchFocusScreen'])
assert.notEqual(one[key], two[key]);
assert.notEqual(one.state.records.byMmsi, two.state.records.byMmsi);
assert.notEqual(one.state.records.byId, two.state.records.byId);
assert.notEqual(one.state.feed, two.state.feed);
assert.notEqual(one.state.records.all, two.state.records.all);
assert.notEqual(one.state.records.unkeyed, two.state.records.unkeyed);
@@ -272,7 +272,7 @@ test('partial-feed expiry releases a selected vessel and its pending trail', asy
layer.selectById('222');
current = snapshot([observation('111')], { complete: false });
await layer.update();
assert.equal(layer.getSelectedInfo().mmsi, '222');
assert.equal(layer.getSelectedInfo().id, '222');
assert.equal(signal.aborted, false);
advance(5 * 60 * 1000);
await layer.update();
@@ -421,3 +421,97 @@ test('vessel stats name the provider the last snapshot reported', async () => {
await layer.update();
assert.equal(layer.getStats().source, 'Datalastic');
});
test('two vessels sharing an MMSI under different ids stay two vessels', async () => {
const shared = (id, longitude) => ({
...observation(id),
mmsi: '123456789',
longitude,
name: `HULL ${id}`,
});
const { layer, primitives } = setup({
getSnapshot: async () =>
snapshot([shared('hull-a', 4.05), shared('hull-b', 4.25)]),
});
await layer.update();
assert.equal(layer.getStats().count, 2);
assert.equal(primitives.size, 2);
assert.equal(layer.hasContact('hull-a'), true);
assert.equal(layer.hasContact('hull-b'), true);
// An MMSI is not a key, but a link naming one still finds a vessel with it.
assert.equal(layer.hasContact('123456789'), true);
assert.equal(layer.hasContact('987654321'), false);
assert.deepEqual(
layer
.getAllPositions()
.map(({ id }) => id)
.sort(),
['hull-a', 'hull-b'],
);
assert.equal(layer.selectById('hull-b'), true);
assert.deepEqual(
[layer.getSelectedInfo().id, layer.getSelectedInfo().mmsi],
['hull-b', '123456789'],
);
assert.equal(
layer.selectById('123456789'),
true,
'a link naming the MMSI selects a vessel with it',
);
assert.equal(layer.getSelectedInfo().mmsi, '123456789');
assert.equal(layer.selectById('hull-b'), true);
// A lookup by the shared MMSI still finds a vessel that has it.
assert.equal(layer.findByQuery('123456789').mmsi, '123456789');
});
test('a vessel with an id and no MMSI renders and can be selected', async () => {
const { layer, primitives } = setup({
getSnapshot: async () =>
snapshot([{ ...observation('7f3c9a10-provider-id'), name: 'NO MMSI' }]),
});
await layer.update();
assert.equal(primitives.size, 1);
const [primitive] = primitives;
assert.equal(primitive.id.id, '7f3c9a10-provider-id');
assert.equal(primitive.id.mmsi, '');
assert.equal(layer.selectById('7f3c9a10-provider-id'), true);
const info = layer.getSelectedInfo();
assert.equal(info.id, '7f3c9a10-provider-id');
assert.equal(info.mmsi, '');
assert.equal(layer.getCockpitSubject().id, '7f3c9a10-provider-id');
assert.equal(layer.testing._getVesselStateForTest().trailId, info.id);
});
test('a vessel keyed by a UUID is found in any case and names its drawn fix', async () => {
const uuid = '0770e3ca-b896-c307-f499-4f9df2b8f86b';
const { layer } = setup({
getSnapshot: async () =>
snapshot([{ ...observation(uuid.toUpperCase()), name: 'UUID HULL' }]),
});
await layer.update();
assert.equal(layer.getSelectedFix(), null, 'nothing selected');
// A share link carries the UUID in lowercase.
assert.equal(layer.hasContact(uuid), true);
assert.equal(layer.selectById(uuid), true);
const fix = layer.getSelectedFix();
assert.equal(fix.id, uuid.toUpperCase());
assert.ok(fix.position, 'where the vessel is drawn');
assert.equal(fix.observedAtMs, 500);
assert.equal(layer.hasContact('0770e3ca-b896-c307-f499-4f9df2b8f86c'), false);
});
test('AIS feed rows stay keyed by their MMSI', async () => {
const { vesselSnapshot } = await import('../../sources/live/vessels.js');
const row = { mmsi: '235000001', lat: 51.93, lon: 4.05, name: 'TERN' };
const { layer } = setup({
getSnapshot: async () =>
vesselSnapshot({ rows: [row, { ...row, lon: 4.2 }], status: 'live' }),
});
await layer.update();
assert.equal(layer.getStats().count, 1, 'one MMSI is one vessel on AIS');
assert.equal(layer.selectById('235000001'), true);
assert.deepEqual(
[layer.getSelectedInfo().id, layer.getSelectedInfo().mmsi],
['235000001', '235000001'],
);
});
+109 -48
View File
@@ -28,6 +28,9 @@ import {
} from './policy.js';
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
const UUID_KEY =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function createQueries({
vesselState,
services,
@@ -38,6 +41,28 @@ export function createQueries({
const { state } = vesselState;
const { formatKnots } = services.labels;
/**
* The vessel keyed `id`, else the first whose MMSI is `id`: a link or a
* caller that names a vessel by its MMSI still finds it. A share link
* carries a UUID key in lowercase, so a UUID matches in any case.
* @param {string} id
* @returns {object|null}
*/
function recordFor(id) {
const record = state.records.byId?.get(id);
if (record) return record;
if (UUID_KEY.test(id)) {
const wanted = id.toLowerCase();
for (const candidate of state.records.byId?.values() ?? [])
if (candidate.id.toLowerCase() === wanted) return candidate;
return null;
}
if (!/^\d{1,10}$/.test(id)) return null;
for (const candidate of state.records.byId?.values() ?? [])
if (candidate.mmsi === id) return candidate;
return null;
}
/**
* Seconds until the server's next reconnect attempt, or 0 when none is
* scheduled. Mirrors the flights layer's `retryInSec` chip affordance.
@@ -175,8 +200,9 @@ export function createQueries({
* fields are null, never NaN/undefined. navStatus is always null: the
* /api/vessels proxy does not surface AIS NavigationalStatus, so it
* cannot be derived client-side.
* @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry.
* @returns {{id: string|null, mmsi: string|null, name: string|null,
* @param {Object|null|undefined} record - `state.records.byId`/`state.records.all` entry.
* @returns {{id: string|null, vesselId: string|null, mmsi: string|null,
* name: string|null,
* lat: number|null, lon: number|null, speedKts: number|null,
* courseDeg: number|null, shipType: string|null, destination: string|null,
* navStatus: null}}
@@ -188,10 +214,14 @@ export function createQueries({
const t = String(v ?? '').trim();
return t || null;
};
const vesselId = text(record?.id);
const mmsi = text(record?.mmsi);
const name = text(record?.name);
return {
id: name || mmsi,
// A display label, as every layer's analyst `id` is; `vesselId` is the
// key and `mmsi` the vessel's MMSI when known.
id: name || mmsi || vesselId,
vesselId,
mmsi,
name,
lat: num(record?.lat),
@@ -225,32 +255,32 @@ export function createQueries({
* trail picks as no-ops. The interaction wire also reserves sibling-owned
* picks before this reducer so their camera action cannot mutate AIS state.
*
* @param {{selectedMmsi?: string|number|null, pickedMmsi?: string|number|null,
* @param {{selectedId?: string|number|null, pickedId?: string|number|null,
* gesture?: 'click'|'escape'}} input - Current selection plus owned pick.
* @returns {{action: 'none'|'select'|'deselect'}}
*/
function reduceVesselSelection(input = {}) {
const selectedMmsi = normalizeSelectionMmsi(input.selectedMmsi);
const pickedMmsi = normalizeSelectionMmsi(input.pickedMmsi);
const selectedId = normalizeSelectionId(input.selectedId);
const pickedId = normalizeSelectionId(input.pickedId);
const gesture = input.gesture || 'click';
if (gesture === 'escape') {
return selectedMmsi ? { action: 'deselect' } : { action: 'none' };
return selectedId ? { action: 'deselect' } : { action: 'none' };
}
if (gesture !== 'click') {
return { action: 'none' };
}
if (pickedMmsi) {
if (pickedMmsi === selectedMmsi) {
if (pickedId) {
if (pickedId === selectedId) {
return { action: 'none' };
}
return { action: 'select' };
}
return selectedMmsi ? { action: 'deselect' } : { action: 'none' };
return selectedId ? { action: 'deselect' } : { action: 'none' };
}
function normalizeSelectionMmsi(value) {
function normalizeSelectionId(value) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text || null;
@@ -275,9 +305,10 @@ export function createQueries({
statsRefreshInterval: 1000,
/**
* Find a vessel by exact MMSI or case-insensitive name substring.
* @param {string|number} query MMSI or partial vessel name.
* @returns {{ mmsi: string, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
* Find a vessel by its exact id, exact MMSI or case-insensitive name
* substring. Vessels can share an MMSI; the first that has it is found.
* @param {string|number} query Vessel id, MMSI or partial vessel name.
* @returns {{ id: string, mmsi: string, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
*/
findByQuery(query) {
if (query === null || query === undefined) return null;
@@ -286,9 +317,9 @@ export function createQueries({
const q = String(query).trim();
if (!q) return null;
let record = null;
if (/^\d+$/.test(q)) {
record = state.records.byMmsi.get(q) || null;
let record = state.records.byId.get(q) || null;
if (!record && /^\d+$/.test(q)) {
record = records.find((r) => r.mmsi === q) || null;
}
if (!record) {
const lower = q.toLowerCase();
@@ -306,6 +337,7 @@ export function createQueries({
components.rendering.getVisual(record).position;
if (!position) return null;
return {
id: record.id,
mmsi: record.mmsi,
name: record.name,
position,
@@ -322,7 +354,7 @@ export function createQueries({
* @param {Cesium.Cartesian3} centerCartesian Center of the search.
* @param {number} rangeM Max distance in meters (non-finite = unbounded).
* @param {number} [maxCount=25] Maximum entries to return.
* @returns {Array<{ mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>}
* @returns {Array<{ id: string, mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>}
*/
getNearby(centerCartesian, rangeM, maxCount = 25) {
const records = state.records.all;
@@ -342,6 +374,7 @@ export function createQueries({
const distanceM = Cesium.Cartesian3.distance(centerCartesian, position);
if (!Number.isFinite(distanceM) || distanceM > range) continue;
entries.push({
id: record.id,
mmsi: record.mmsi,
name: record.name,
position,
@@ -363,7 +396,7 @@ export function createQueries({
* @returns {Array<{ id: string, name: string, latitude: number,
* longitude: number, course: number|null, heading: number|null,
* type: string, color: string, stale: boolean }>} Empty while the
* layer is off. A ship with no MMSI has an `unkeyed:` id.
* layer is off. A ship with no id has an `unkeyed:` id.
*/
getRadarContacts(lat, lon, rangeM, maxCount = 60) {
const records = state.records.all;
@@ -373,7 +406,7 @@ export function createQueries({
const cap =
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 60;
const center = { lat, lon };
const subject = state.cockpitSubjectMmsi;
const subject = state.cockpitSubjectId;
const finite = (value) => {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
@@ -382,7 +415,7 @@ export function createQueries({
const entries = [];
records.forEach((record, index) => {
if (subject && String(record.mmsi) === subject) return;
if (subject && String(record.id) === subject) return;
const distanceM = greatCircleMeters(center, {
lat: record.lat,
lon: record.lon,
@@ -392,8 +425,8 @@ export function createQueries({
});
entries.sort((a, b) => a.distanceM - b.distanceM);
return entries.slice(0, cap).map(({ record, index }) => ({
// A ship with no MMSI still needs a key of its own on the scope.
id: record.mmsi ? String(record.mmsi) : `unkeyed:${index}`,
// A ship with no id still needs a key of its own on the scope.
id: record.id ? String(record.id) : `unkeyed:${index}`,
name: String(record.name || record.mmsi || ''),
latitude: record.lat,
longitude: record.lon,
@@ -414,22 +447,22 @@ export function createQueries({
* Whether this layer still carries a vessel, in O(1).
*
* Mirror of `flights.hasContact`: presence consumers must not infer absence
* from the capped `getAllPositions` rows. `vesselMap` is MMSI-keyed.
* from the capped `getAllPositions` rows. Records are keyed by id.
* A disabled layer keeps its records, so it must decline rather than answer
* from data the user can no longer see.
* @param {string} mmsi Vessel identifier.
* @param {string} id Vessel id.
* @returns {boolean|null} Presence, or null when the layer is disabled or
* holds no data and therefore cannot answer.
*/
hasContact(mmsi) {
hasContact(id) {
if (
!state.feed.enabled ||
!state.records.byMmsi ||
state.records.byMmsi.size === 0
!state.records.byId ||
state.records.byId.size === 0
)
return null;
if (!mmsi) return false;
return state.records.byMmsi.has(String(mmsi).trim());
if (!id) return false;
return Boolean(recordFor(String(id).trim()));
},
getAllPositions(maxCount = 800) {
@@ -445,8 +478,8 @@ export function createQueries({
const position = visual.billboard?.position || visual.position;
if (!position) continue;
result.push({
id: record.mmsi,
label: record.name || record.mmsi,
id: record.id,
label: record.name || record.mmsi || record.id,
position,
latitude: record.lat,
longitude: record.lon,
@@ -479,16 +512,16 @@ export function createQueries({
},
/**
* Select a vessel by MMSI via the same path as a map click
* (highlight + HUD update).
* @param {string|number} mmsi Vessel MMSI.
* Select a vessel by id via the same path as a map click
* (highlight + HUD update). An AIS feed's vessels are keyed by MMSI.
* @param {string|number} id Vessel id.
* @returns {boolean} True if a matching vessel was selected.
*/
selectById(mmsi) {
if (mmsi === null || mmsi === undefined) return false;
const target = String(mmsi).trim();
selectById(id) {
if (id === null || id === undefined) return false;
const target = String(id).trim();
if (!target) return false;
const record = state.records.byMmsi.get(target);
const record = recordFor(target);
if (!record) return false;
components.selection.selectVessel(record);
return true;
@@ -505,12 +538,13 @@ export function createQueries({
/**
* Get info about the currently selected vessel.
* @returns {{ mmsi: string, name: string, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
* @returns {{ id: string, mmsi: string, name: string, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
*/
getSelectedInfo() {
const record = state.selectedRecord;
if (!record) return null;
return {
id: record.id,
mmsi: record.mmsi,
name: record.name,
latitude: record.lat,
@@ -521,6 +555,33 @@ export function createQueries({
};
},
/**
* Where the selected vessel is drawn now, for a share link that names it:
* its id, its drawn position and the time that position stands for (the
* world time while the layer follows the clock, else its fix's time).
* @returns {{ id: string, position: Cesium.Cartesian3, observedAtMs: number|null }|null}
* Null when the layer is off, nothing is selected or it is not drawn.
*/
getSelectedFix() {
if (!state.feed.enabled) return null;
const record = state.selectedRecord;
if (!record?.id) return null;
const visual = components.rendering.getVisual(record);
const position = visual?.billboard?.position || visual?.position;
if (!position) return null;
const fixEpoch = record.lastPositionEpoch;
const observedAtMs = followsClock(vesselState)
? vesselState.worldNow()
: Number.isFinite(fixEpoch)
? fixEpoch * 1000
: (record.fixReceivedAtMs ?? null);
return {
id: record.id,
position,
observedAtMs: Number.isFinite(observedAtMs) ? observedAtMs : null,
};
},
/**
* Describe the selected vessel for the Cockpit bridge view.
* @returns {object|null} `vesselCockpitInfo` fields plus `seaSurfaceHeightM`
@@ -546,16 +607,16 @@ export function createQueries({
/**
* Mark the vessel whose bridge the Cockpit camera occupies, or release it.
* Its sprite, card and detection bracket stay hidden while it is held.
* @param {string|number|null} mmsi Subject MMSI, or null to release.
* @param {string|number|null} id Subject vessel id, or null to release.
* @returns {boolean} Always true.
*/
setCockpitSubject(mmsi) {
const next = normalizeSelectionMmsi(mmsi);
if (state.cockpitSubjectMmsi === next) return true;
state.cockpitSubjectMmsi = next;
setCockpitSubject(id) {
const next = normalizeSelectionId(id);
if (state.cockpitSubjectId === next) return true;
state.cockpitSubjectId = next;
// The ridden ship's trail runs from under the eye: hidden, its history
// kept, while the bridge holds the ship it belongs to.
state.trail?.setVisible?.(!next || next !== state.trailMmsi);
state.trail?.setVisible?.(!next || next !== state.trailId);
components.rendering.updateVisibility(true);
return true;
},
@@ -608,7 +669,7 @@ export function createQueries({
if (!position) continue;
result.push({
position,
sourceId: record.mmsi,
sourceId: record.id,
id: record.name || record.mmsi || 'VESSEL',
type: 'SEA',
skipLabel: record === selected,
@@ -676,7 +737,7 @@ export function createQueries({
mapAnalystRecord,
vesselDatumHeightM,
reduceVesselSelection,
normalizeSelectionMmsi,
normalizeSelectionId,
methods,
};
}
+1 -1
View File
@@ -134,7 +134,7 @@ test("the ridden ship's trail is hidden while the bridge holds it", () => {
};
const layer = layerWith([record('own', 0), record('other', 2)], {
trail,
trailMmsi: 'own',
trailId: 'own',
});
layer.setCockpitSubject('own');
assert.deepEqual(shown, [false]);
+32 -23
View File
@@ -3,17 +3,25 @@ import {
SELECTED_PIN_REFRESHES,
} from './recordPolicy.js';
/** Normalize AIS display fields without allocating scene resources. */
/**
* Normalize AIS display fields without allocating scene resources. `id` is the
* vessel's key, the stable id its source gives; a row that names none (an
* AIS feed's own rows) is keyed by its MMSI. `mmsi` is only an attribute,
* shown when known: vessels can share one. A row with neither is unkeyed.
*/
export function normalizeVessel(row) {
const lat = Number(row.lat);
const lon = Number(row.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const mmsi = String(row.mmsi || '').trim();
const id = String(row.id ?? mmsi).trim();
return {
lat,
lon,
name: String(row.name || row.mmsi || 'VESSEL'),
mmsi: String(row.mmsi || '').trim(),
reference: row.reference ?? String(row.mmsi || '').trim(),
name: String(row.name || mmsi || 'VESSEL'),
id,
mmsi,
reference: row.reference ?? id,
imo: String(row.imo || ''),
type: String(row.type || ''),
destination: String(row.destination || ''),
@@ -68,7 +76,7 @@ function motionBetween(record, next) {
export class VesselRecords {
constructor({ now = () => Date.now() } = {}) {
this.now = now;
this.byMmsi = new Map();
this.byId = new Map();
this.unkeyed = [];
this.all = [];
}
@@ -112,14 +120,14 @@ export class VesselRecords {
if (!next) continue;
next.receivedAtMs = receivedAtMs;
stampFix(next, receivedAtMs, feedLatestEpoch);
if (!next.mmsi) {
if (!next.id) {
effects.add(next);
this.unkeyed.push(next);
continue;
}
if (seen.has(next.mmsi)) continue;
seen.add(next.mmsi);
const record = this.byMmsi.get(next.mmsi);
if (seen.has(next.id)) continue;
seen.add(next.id);
const record = this.byId.get(next.id);
if (record) {
const before = effects.beforeUpdate(record);
// A feed re-serving a cached row repeats its fix: the fix keeps the
@@ -131,6 +139,7 @@ export class VesselRecords {
if (replace) record.motion = null;
else if (follow) record.motion = motionBetween(record, next);
record.reference = next.reference;
record.mmsi = next.mmsi;
record.receivedAtMs = next.receivedAtMs;
record.lat = next.lat;
record.lon = next.lon;
@@ -148,15 +157,15 @@ export class VesselRecords {
effects.updated(record, before);
} else {
effects.add(next);
this.byMmsi.set(next.mmsi, next);
this.byId.set(next.id, next);
}
}
for (const [mmsi, record] of this.byMmsi) {
if (seen.has(mmsi)) continue;
for (const [id, record] of this.byId) {
if (seen.has(id)) continue;
if (replace) {
effects.remove(record, record === selectedRecord);
this.byMmsi.delete(mmsi);
effects.removed(mmsi);
this.byId.delete(id);
effects.removed(id);
continue;
}
if (
@@ -179,18 +188,18 @@ export class VesselRecords {
}
// Selection teardown still sees the record before its store entry is removed.
effects.remove(record, record === selectedRecord);
this.byMmsi.delete(mmsi);
effects.removed(mmsi);
this.byId.delete(id);
effects.removed(id);
}
if (this.byMmsi.size + this.unkeyed.length > cap) {
for (const [mmsi, record] of this.byMmsi) {
if (this.byMmsi.size + this.unkeyed.length <= cap) break;
if (seen.has(mmsi) || record === selectedRecord) continue;
if (this.byId.size + this.unkeyed.length > cap) {
for (const [id, record] of this.byId) {
if (this.byId.size + this.unkeyed.length <= cap) break;
if (seen.has(id) || record === selectedRecord) continue;
effects.remove(record, false);
this.byMmsi.delete(mmsi);
effects.removed(mmsi);
this.byId.delete(id);
effects.removed(id);
}
}
this.all = [...this.byMmsi.values(), ...this.unkeyed];
this.all = [...this.byId.values(), ...this.unkeyed];
}
}
+56 -16
View File
@@ -18,8 +18,8 @@ function setup() {
beforeUpdate: (record) => record.type,
updated: (record, before) => events.push(['update', record, before]),
remove: (record, evicted) =>
events.push(['remove', record, evicted, store.byMmsi.has(record.mmsi)]),
removed: (id) => events.push(['removed', id, store.byMmsi.has(id)]),
events.push(['remove', record, evicted, store.byId.has(record.id)]),
removed: (id) => events.push(['removed', id, store.byId.has(id)]),
staleSelected: (record) => events.push(['stale', record]),
};
return {
@@ -40,14 +40,14 @@ test('vessel reconciliation preserves record identity and first duplicate while
row('222', { name: 'duplicate' }),
row('bad', { lat: NaN }),
]);
assert.equal(probe.store.byMmsi.size, 2);
assert.equal(probe.store.byMmsi.get('222').name, '222');
const record = probe.store.byMmsi.get('111');
assert.equal(probe.store.byId.size, 2);
assert.equal(probe.store.byId.get('222').name, '222');
const record = probe.store.byId.get('111');
probe.reconcile([row('111', { lat: 51.94, type: 'Tanker' })]);
assert.equal(probe.store.byMmsi.get('111'), record);
assert.equal(probe.store.byId.get('111'), record);
assert.equal(record.lat, 51.94);
assert.equal(probe.events.find((event) => event[0] === 'update')[2], 'Cargo');
assert.equal(probe.store.byMmsi.has('222'), false);
assert.equal(probe.store.byId.has('222'), false);
for (const field of ['position', 'surfacePosition', 'normal', 'billboard'])
assert.equal(Object.hasOwn(record, field), false);
assert.deepEqual(structuredClone(record), record);
@@ -56,14 +56,14 @@ test('vessel reconciliation preserves record identity and first duplicate while
test('selected vessel remains pinned for three complete misses and teardown precedes removal', () => {
const probe = setup();
probe.reconcile([row('111'), row('222')]);
const selectedRecord = probe.store.byMmsi.get('111');
const selectedRecord = probe.store.byId.get('111');
for (let i = 1; i <= 3; i++) {
probe.reconcile([row('222')], { selectedRecord });
assert.equal(probe.store.byMmsi.get('111'), selectedRecord);
assert.equal(probe.store.byId.get('111'), selectedRecord);
assert.equal(selectedRecord.missedRefreshes, i);
}
probe.reconcile([row('222')], { selectedRecord });
assert.equal(probe.store.byMmsi.has('111'), false);
assert.equal(probe.store.byId.has('111'), false);
const removal = probe.events.find(
(event) => event[0] === 'remove' && event[1] === selectedRecord,
);
@@ -77,13 +77,13 @@ test('selected vessel remains pinned for three complete misses and teardown prec
test('partial retention respects age and row cap while preserving the selected contact', () => {
const probe = setup();
probe.reconcile([row('111'), row('222')], { cap: 2 });
const selectedRecord = probe.store.byMmsi.get('111');
const selectedRecord = probe.store.byId.get('111');
probe.reconcile([row('333')], { complete: false, selectedRecord, cap: 2 });
assert.deepEqual([...probe.store.byMmsi.keys()], ['111', '333']);
assert.deepEqual([...probe.store.byId.keys()], ['111', '333']);
assert.equal(selectedRecord.missedRefreshes, 1);
probe.advance(5 * 60 * 1000);
probe.reconcile([row('333')], { complete: false, selectedRecord, cap: 2 });
assert.equal(probe.store.byMmsi.has('111'), false);
assert.equal(probe.store.byId.has('111'), false);
assert.equal(
probe.events.find(
(event) => event[0] === 'remove' && event[1] === selectedRecord,
@@ -107,7 +107,7 @@ test('unkeyed records rebuild on each refresh and numeric coercion keeps unknown
),
true,
);
assert.equal(probe.store.byMmsi.size, 0);
assert.equal(probe.store.byId.size, 0);
assert.equal(probe.store.all.length, 1);
assert.equal(normalizeVessel(row('bad', { lon: Infinity })), null);
});
@@ -120,14 +120,14 @@ test("each fix keeps when it was first received and its refresh's newest fix", (
row('333'),
]);
for (const mmsi of ['111', '222', '333']) {
const record = probe.store.byMmsi.get(mmsi);
const record = probe.store.byId.get(mmsi);
assert.equal(record.fixFeedEpoch, 1_800_000_030, mmsi);
assert.equal(record.fixReceivedAtMs, 1000, mmsi);
}
// A new fix is stamped afresh.
probe.advance(60_000);
probe.reconcile([row('111', { last_position_epoch: 1_800_000_050 })]);
const record = probe.store.byMmsi.get('111');
const record = probe.store.byId.get('111');
assert.equal(record.fixFeedEpoch, 1_800_000_050);
assert.equal(record.fixReceivedAtMs, 61_000);
// The same fix re-served by later polls keeps its first receipt, while
@@ -141,3 +141,43 @@ test("each fix keeps when it was first received and its refresh's newest fix", (
assert.equal(record.fixFeedEpoch, 1_800_000_050);
assert.equal(record.receivedAtMs, 91_000);
});
test('vessels are keyed by their source id; a shared MMSI is only an attribute', () => {
const probe = setup();
probe.reconcile([
row('123456789', { id: 'hull-a', name: 'A' }),
row('123456789', { id: 'hull-b', name: 'B' }),
row('', { id: 'hull-c', name: 'C' }),
]);
assert.deepEqual(
[...probe.store.byId.keys()],
['hull-a', 'hull-b', 'hull-c'],
);
assert.equal(probe.store.unkeyed.length, 0);
const [a, b, c] = probe.store.all;
assert.deepEqual([a.mmsi, b.mmsi, c.mmsi], ['123456789', '123456789', '']);
assert.equal(a.reference, 'hull-a');
// The number moves to another hull: the vessel keeps its id.
probe.reconcile([
row('', { id: 'hull-a', name: 'A' }),
row('123456789', { id: 'hull-b', name: 'B' }),
row('123456789', { id: 'hull-c', name: 'C' }),
]);
assert.equal(probe.store.byId.get('hull-a'), a);
assert.equal(a.mmsi, '');
assert.equal(probe.store.byId.get('hull-c').mmsi, '123456789');
});
test('an AIS row with no id is keyed by its MMSI; one with neither is unkeyed', () => {
assert.deepEqual(
[
normalizeVessel(row('366999712')).id,
normalizeVessel(row('366999712')).mmsi,
],
['366999712', '366999712'],
);
const probe = setup();
probe.reconcile([row(''), row('', { id: '' })]);
assert.equal(probe.store.byId.size, 0);
assert.equal(probe.store.unkeyed.length, 2);
});
+10 -10
View File
@@ -307,7 +307,7 @@ export function createRendering({
if (visual.billboard) {
// The bridge camera sits on this sprite; showing it would fill the view.
visual.billboard.show =
visible && record.mmsi !== state.cockpitSubjectMmsi;
visible && record.id !== state.cockpitSubjectId;
if (visible && doRotations && scene) {
const rot = screenProjectedRotation(
scene,
@@ -445,8 +445,7 @@ export function createRendering({
const selected = state.selectedRecord;
// From the bridge, the selected vessel is the viewer; its card would be
// pinned to the camera position.
const selectedCarded =
selected && selected.mmsi !== state.cockpitSubjectMmsi;
const selectedCarded = selected && selected.id !== state.cockpitSubjectId;
const entries = selectedCarded
? [components.cards.buildSelectedVesselCard(selected)]
: [];
@@ -532,17 +531,17 @@ export function createRendering({
VESSEL_CARD_FADE_DISTANCE_M,
);
if (!card.interactive) return card;
const mmsi = String(card.id || '').startsWith('vessel:')
const id = String(card.id || '').startsWith('vessel:')
? card.id.slice('vessel:'.length)
: '';
const mmsi = state.records.byId.get(id)?.mmsi || '';
return {
...card,
accessibilityLabel: t('Focus vessel {name}, MMSI {mmsi}', {
name: card.title,
mmsi,
}),
accessibilityLabel: mmsi
? t('Focus vessel {name}, MMSI {mmsi}', { name: card.title, mmsi })
: t('Focus {label}', { label: card.title }),
activate: () => {
const record = state.records.byMmsi.get(mmsi);
const record = state.records.byId.get(id);
if (!record) return false;
components.selection.selectAndFocusVessel(record);
return true;
@@ -574,7 +573,8 @@ export function createRendering({
text &&
text !== 'VESSEL' &&
!/^MMSI\s*\d+$/i.test(text) &&
text !== record.mmsi,
text !== record.mmsi &&
text !== record.id,
);
}
+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,
+4 -4
View File
@@ -44,7 +44,7 @@ export function createVesselSnapshotRenderer({
const nextIcon = rendering.shipIcon(record, selected);
if (nextIcon !== prevIcon) visual.billboard.image = nextIcon;
}
if (record.mmsi === state.trailMmsi)
if (record.id === state.trailId)
tracking.appendSelectedVesselTrailFix(record);
if (selected) {
cards.updateSelectedVesselHud(record);
@@ -55,8 +55,8 @@ export function createVesselSnapshotRenderer({
if (evicted) selection.clearVesselInspection({ evicted: true });
rendering.removeRecordPrimitives(record);
},
removed(mmsi) {
if (state.trailMmsi === mmsi) tracking.clearSelectedVesselTrail();
removed(id) {
if (state.trailId === id) tracking.clearSelectedVesselTrail();
},
staleSelected(record) {
cards.updateSelectedVesselHud(record);
@@ -64,7 +64,7 @@ export function createVesselSnapshotRenderer({
},
);
// The selected vessel at the new time: its trail starts there.
if (replace && state.selectedRecord && state.trailMmsi)
if (replace && state.selectedRecord && state.trailId)
tracking.startSelectedVesselTrail(state.selectedRecord);
state.lastVisibilityUpdate = 0;
rendering.updateVisibility(true);
+4 -4
View File
@@ -86,14 +86,14 @@ export function createVesselState({ source, services }) {
activeFocusCount: 0,
activeLabelCount: 0,
selectedRecord: null,
/** @type {string|null} MMSI whose bridge the Cockpit camera occupies; its sprite and card stay hidden. */
cockpitSubjectMmsi: null,
/** @type {string|null} Id of the vessel whose bridge the Cockpit camera occupies; its sprite and card stay hidden. */
cockpitSubjectId: null,
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
trail: null,
/** @type {Cesium.Cartesian3[]} Chronological trail vertices (oldest first) */
trailPositions: [],
/** @type {string|null} MMSI that owns the active selected-vessel trail. */
trailMmsi: null,
/** @type {string|null} Id of the vessel that owns the active selected-vessel trail. */
trailId: null,
/** @type {number} Monotonic token — invalidates in-flight backfill responses */
trailBackfillToken: 0,
trailAbort: null,
+14 -9
View File
@@ -32,6 +32,11 @@ export function createTesting({
function _setVesselStateForTest(options = {}) {
components.lifecycle.resetState();
const records = Array.isArray(options.records) ? options.records : [];
// A test record that names no id is keyed by its MMSI, as an AIS feed's
// own rows are (see normalizeVessel).
for (const record of [...records, options.selectedRecord])
if (record && typeof record === 'object' && record.id === undefined)
record.id = String(record.mmsi || '').trim();
state.viewer = options.viewer || null;
state.feed.enabled = options.enabled !== false;
state.feed.loaded = options.loaded === true;
@@ -42,15 +47,15 @@ export function createTesting({
state.feed.lastUpdate = options.lastUpdate ?? null;
state.records.all = records;
state.feed.count = records.length;
state.records.byMmsi = new Map(
state.records.byId = new Map(
records
.filter((record) => record?.mmsi)
.map((record) => [record.mmsi, record]),
.filter((record) => record?.id)
.map((record) => [record.id, record]),
);
state.selectedRecord = options.selectedRecord || null;
state.billboardCollection = options.billboardCollection || { remove() {} };
state.trail = options.trail || null;
state.trailMmsi = options.trailMmsi || null;
state.trailId = options.trailId || null;
state.trailPositions = Array.isArray(options.trailPositions)
? [...options.trailPositions]
: [];
@@ -141,8 +146,8 @@ export function createTesting({
lastMessageAt: state.feed.lastMessageAt,
rawRowCount: state.feed.rawRowCount,
acceptedRowCount: state.feed.acceptedRowCount,
selectedMmsi: state.selectedRecord?.mmsi || null,
trailMmsi: state.trailMmsi,
selectedId: state.selectedRecord?.id || null,
trailId: state.trailId,
trailPositionCount: state.trailPositions.length,
sessionId: state.feed.sessionId,
firstConnectPhase: state.feed.firstConnectPhase,
@@ -166,14 +171,14 @@ export function createTesting({
/**
* Read lifecycle ownership state without exposing the mutable state object.
* Test-only seam.
* @returns {{trailMmsi: string|null, trailPositionCount: number, vesselCount: number}}
* @returns {{trailId: string|null, trailPositionCount: number, vesselCount: number}}
*/
function _getVesselStateForTest() {
return {
trailMmsi: state.trailMmsi,
trailId: state.trailId,
trailPositionCount: state.trailPositions.length,
vesselCount: state.records.byMmsi.size,
vesselCount: state.records.byId.size,
};
}
return {
+7 -11
View File
@@ -86,7 +86,7 @@ export function createTracking({
state.trailAbort?.abort();
state.trailAbort = new AbortController();
state.trailBackfillToken += 1;
state.trailMmsi = record.mmsi;
state.trailId = record.id;
state.trailPositions = [];
const current = vesselTrailPosition(record);
if (current) state.trailPositions.push(current);
@@ -97,25 +97,21 @@ export function createTracking({
});
}
if (state.trail) state.trail.setPositions(state.trailPositions);
backfillVesselTrail(
record.mmsi,
state.trailBackfillToken,
record.reference,
);
backfillVesselTrail(record.id, state.trailBackfillToken, record.reference);
}
/**
* Fire-and-forget backfill from the server-side per-MMSI ring buffer
* Fire-and-forget backfill from the server-side per-vessel ring buffer
* (PRD F3 — "recent path" since server boot, not voyage history). Older
* samples are spliced AHEAD of the live accumulation, capped at
* TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/malformed)
* silently keeps the live-only trail.
* @param {string} mmsi - MMSI of the selected vessel.
* @param {string} id - Key of the selected vessel.
* @param {number} token - Backfill token captured at request time.
* @returns {Promise<void>}
*/
async function backfillVesselTrail(mmsi, token, reference = mmsi) {
async function backfillVesselTrail(id, token, reference = id) {
const owner = state.trailAbort;
// Following the world clock, the track ends at the world's time.
const atMs = followsClock(vesselState) ? vesselState.worldNow() : undefined;
@@ -139,7 +135,7 @@ export function createTracking({
token !== state.trailBackfillToken
)
return;
if (state.trailMmsi !== mmsi) return;
if (state.trailId !== id) return;
const older = [];
for (const sample of samples) {
@@ -198,7 +194,7 @@ export function createTracking({
state.trailAbort?.abort();
state.trailAbort = null;
state.trailBackfillToken += 1;
state.trailMmsi = null;
state.trailId = null;
state.trailPositions = [];
if (state.trail) state.trail.clear();
}
File diff suppressed because it is too large. Load diff
+27
View File
@@ -59,6 +59,12 @@ export const SHARE_VIEW_FOLLOW_OFFSET_DEFAULT = Object.freeze({
pitch: -35,
});
/**
* The follow range a thing view can carry, in whole metres: 1 to this (the
* codec clamps to the same bound).
*/
export const SHARE_VIEW_FOLLOW_RANGE_MAX_M = 1e8;
const EARTH_RADIUS_M = 6_371_000;
const RAD = Math.PI / 180;
@@ -114,6 +120,27 @@ function enuBasis(lat, lon) {
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
/**
* The follow offset that puts the camera at `camera` (lat, lon, alt) while
* following an entity at `fix`: the inverse of {@link followCameraFor}, on
* the same sphere, in whole metres and degrees. Null when the camera is not
* above the entity's local horizontal (a view from below it, or through the
* earth, has no follow offset), or is at the entity.
*/
export function followOffsetFor(fix, camera) {
const at = toEcef(fix.lat, fix.lon, fix.alt);
const from = toEcef(camera.lat, camera.lon, camera.alt);
const basis = enuBasis(fix.lat, fix.lon);
const delta = [0, 1, 2].map((i) => from[i] - at[i]);
const local = {
east: dot(delta, basis.east),
north: dot(delta, basis.north),
up: dot(delta, basis.up),
};
if (!(local.up > 0)) return null;
return followOffsetFromEnu(local);
}
/**
* Where the camera sits while following an entity at `fix` from `offset`,
* and how it looks back at the entity, on a sphere of radius 6 371 000 m. A
+60 -5
View File
@@ -62,7 +62,9 @@ import {
export {
SHARE_VIEW_ENTITY_KINDS,
SHARE_VIEW_FOLLOW_OFFSET_DEFAULT,
SHARE_VIEW_FOLLOW_RANGE_MAX_M,
followCameraFor,
followOffsetFor,
followOffsetFromEnu,
followOffsetToEnu,
} from './follow.js';
@@ -387,8 +389,9 @@ const fieldNumber = (key) => FIELD_BY_KEY.get(key).number;
/**
* Fields inside the thing message (top-level field 13). Permanent. Fields 1–4
* are the entity ids, one per SHARE_VIEW_ENTITY_KINDS code; exactly one is
* present. The follow offset and the last-known fix are flat fields here, not
* are the entity ids, one per SHARE_VIEW_ENTITY_KINDS code, and field 13 is a
* vessel id that is a UUID, as its 16 bytes; exactly one of them is present.
* The follow offset and the last-known fix are flat fields here, not
* sub-messages, to save their tag and length bytes.
*/
export const SHARE_VIEW_THING_FIELDS = Object.freeze({
@@ -400,8 +403,17 @@ export const SHARE_VIEW_THING_FIELDS = Object.freeze({
alt: 10, // sint metres, its last-known altitude
ageS: 11, // sint, seconds the fix predates the created-at time (field 8)
observedAt: 12, // epoch, when the fix was taken; only without a created-at
vesselUuid: 13, // LEN, 16 bytes: a vessel id that is a UUID
});
/**
* A vessel id that is a UUID, as a source's stable id may be: lowercase,
* dashed. It travels as its 16 bytes (thing field 13), not as text.
*/
export const SHARE_VIEW_UUID_GRAMMAR =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const UUID_BYTES = 16;
/** The default of a field or flag for a view in `style`. */
function defaultOf(entry, style) {
if (entry.default !== BY_STYLE) return entry.default;
@@ -490,7 +502,10 @@ function normalizeEntityId(entry, value) {
: typeof value === 'string'
? value.trim().toLowerCase()
: null;
if (raw === null || !TRACKING_ID_GRAMMAR.test(raw)) return null;
if (raw === null) return null;
// A vessel's source may key it by a UUID, longer than the id grammar.
if (entry.kind === 'vessel' && SHARE_VIEW_UUID_GRAMMAR.test(raw)) return raw;
if (!TRACKING_ID_GRAMMAR.test(raw)) return null;
if (entry.kind === 'satellite' && !/^[1-9]\d{0,14}$/.test(raw)) return null;
return raw;
}
@@ -795,6 +810,31 @@ const REGISTRY_BY_TOKEN = new Map(
const charNumber = (char) => LAYER_STATE_TOKEN_ALPHABET.indexOf(char);
const CANONICAL_INTEGER = /^(0|-?[1-9]\d{0,14})$/;
/** The 16 bytes of a UUID in SHARE_VIEW_UUID_GRAMMAR. */
function uuidBytes(id) {
const hex = id.replaceAll('-', '');
return Array.from({ length: UUID_BYTES }, (_, i) =>
Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16),
);
}
/** The lowercase dashed UUID that `bytes` spell. */
function uuidText(bytes) {
const hex = Array.from(bytes, (byte) =>
byte.toString(16).padStart(2, '0'),
).join('');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join('-');
}
const isVesselUuid = (entry, id) =>
entry.kind === 'vessel' && SHARE_VIEW_UUID_GRAMMAR.test(id);
function writeEntityId(writer, entry, id) {
const field = entry.code;
if (entry.idForm === 'hex24' && /^[0-9a-f]{6}$/.test(id))
@@ -895,7 +935,8 @@ function encodeThing(writer, view, state) {
const f = SHARE_VIEW_THING_FIELDS;
const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT;
const thing = new WireWriter();
writeEntityId(thing, entry, view.entity.id);
const uuid = isVesselUuid(entry, view.entity.id);
if (!uuid) writeEntityId(thing, entry, view.entity.id);
const { range, heading, pitch } = view.offset;
if (range !== d.range) thing.uintField(f.range, range);
if (heading !== d.heading) thing.sintField(f.heading, signedAngle(heading));
@@ -914,6 +955,8 @@ function encodeThing(writer, view, state) {
} else if (observed !== null)
thing.sintField(f.observedAt, epochSeconds(observed));
}
// Fields go in ascending order, so the UUID id (field 13) comes last.
if (uuid) thing.bytesField(f.vesselUuid, uuidBytes(view.entity.id));
writer.bytesField(fieldNumber('thing'), thing.finish());
}
@@ -1063,7 +1106,9 @@ const TOP_LEVEL_WIRE_TYPES = new Map(
const THING_WIRE_TYPES = new Map([
...SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, [WIRE_VARINT, WIRE_LEN]]),
...Object.values(SHARE_VIEW_THING_FIELDS).map((n) => [n, [WIRE_VARINT]]),
[SHARE_VIEW_THING_FIELDS.vesselUuid, [WIRE_LEN]],
]);
const VESSEL_ENTRY = SHARE_VIEW_ENTITY_KINDS.find((e) => e.kind === 'vessel');
const unz = unzigzag;
const CLOSED_ENUMS = new Set(['detectionMode', 'detectionAllocation']);
@@ -1258,8 +1303,18 @@ function decodeThing(reader, createdAtS, unknown) {
? followCameraFor(lastKnown, normalizeFollowOffset(offset))
: null;
const present = SHARE_VIEW_ENTITY_KINDS.filter((e) => fields.has(e.code));
if (present.length > 1)
const uuid = fields.get(F.vesselUuid);
if (present.length + (uuid ? 1 : 0) > 1)
throw new ShareWireError('duplicate-field', 'thing entity id');
if (uuid) {
const bytes = uuid.len.rest();
if (bytes.length !== UUID_BYTES)
throw new ShareWireError('bad-length', 'thing vessel id');
return {
camera,
view: thingView(VESSEL_ENTRY.kind, uuidText(bytes), offset, lastKnown),
};
}
const entry = present[0];
if (!entry) {
// A newer app's entity kind uses a thing field this decoder skipped.
+212
View File
@@ -43,14 +43,17 @@ import {
BY_STYLE,
SHARE_VIEW_FLAGS,
SHARE_VIEW_FOLLOW_OFFSET_DEFAULT,
SHARE_VIEW_FOLLOW_RANGE_MAX_M,
SHARE_VIEW_STYLE_DEFAULTS,
SHARE_VIEW_LAYER_OPTION_ABSENT,
SHARE_VIEW_PANELS,
SHARE_VIEW_STYLE_PARAMS,
SHARE_VIEW_THING_FIELDS,
createDefaultShareViewState,
decodeShareView,
encodeShareView,
followCameraFor,
followOffsetFor,
followOffsetFromEnu,
followOffsetToEnu,
layerTokenFromNumber,
@@ -149,6 +152,7 @@ function randomState(random) {
String(int(100000000, 999999999)),
'002442000',
'tmp-7',
'0770e3ca-b896-c307-f499-4f9df2b8f86b',
])
: pick(random, [
int(0, 0xffffff).toString(16).padStart(6, '0'),
@@ -1359,3 +1363,211 @@ test('the world time is written only away from live, and may lie ahead', () => {
encodeShareView({ sharedAtMs: NOW_MS }).length,
);
});
/* ---------------- vessel ids that are UUIDs ---------------- */
const VESSEL_UUID = '0770e3ca-b896-c307-f499-4f9df2b8f86b';
const VESSEL_FIX = {
lat: 51.9301,
lon: 4.0512,
alt: 47,
observedAtMs: NOW_MS - 30_000,
};
const VESSEL_OFFSET = { range: 3200, heading: 20, pitch: -30 };
const vesselView = (id, extra = {}) => ({
view: {
kind: 'thing',
entity: { kind: 'vessel', id },
offset: VESSEL_OFFSET,
lastKnown: VESSEL_FIX,
},
sharedAtMs: NOW_MS,
...extra,
});
/** A thing message's bytes, wrapped as a whole payload's code. */
function thingCode(write) {
const thing = new WireWriter();
write(thing);
const writer = new WireWriter();
writer.bytes.push(1);
writer.bytesField(13, thing.finish());
return hashOf(writer.finish());
}
test('a vessel id that is a UUID travels as its 16 bytes and round-trips', () => {
assert.equal(SHARE_VIEW_THING_FIELDS.vesselUuid, 13);
const code = assertRoundTrip(vesselView(VESSEL_UUID));
const { state } = decodeOk(code);
assert.deepEqual(state.view.entity, { kind: 'vessel', id: VESSEL_UUID });
// Upper case is the same id: the decoder gives it back lowercase.
assert.equal(
decodeOk(encodeShareView(vesselView(VESSEL_UUID.toUpperCase()))).state.view
.entity.id,
VESSEL_UUID,
);
// 16 bytes and a two-byte tag and length, not 36 characters of text.
const aircraft = encodeShareView({
...vesselView('a1b2c3'),
view: {
...vesselView('a1b2c3').view,
entity: { kind: 'aircraft', id: 'a1b2c3' },
},
});
assert.equal(aircraft.length, 44);
assert.equal(code.length, 62);
assert.equal(
payloadBytes(code).length - payloadBytes(aircraft).length,
18 - 5,
'the id costs 18 bytes where an ICAO address costs 5',
);
// The id field comes last, after the fix, in ascending order.
const bytes = payloadBytes(code);
assert.deepEqual(
[...bytes.slice(-18)],
[
0x6a,
16,
...VESSEL_UUID.replaceAll('-', '')
.match(/../g)
.map((h) => parseInt(h, 16)),
],
);
// A UUID form is only a vessel's: other kinds keep the id grammar.
for (const kind of ['aircraft', 'military_aircraft', 'satellite'])
assert.deepEqual(
normalizeShareViewState({
view: { kind: 'thing', entity: { kind, id: VESSEL_UUID } },
}).view,
{ kind: 'place' },
);
// Near-UUIDs are not UUIDs, and are longer than the id grammar allows.
for (const id of [VESSEL_UUID.replaceAll('-', '') + '0', `${VESSEL_UUID}0`])
assert.deepEqual(
normalizeShareViewState({
view: { kind: 'thing', entity: { kind: 'vessel', id } },
}).view,
{ kind: 'place' },
);
});
test('MMSI and short text vessel ids, and links made before UUIDs, are unchanged', () => {
// Codes written before vessel ids could be UUIDs.
const before = {
'AUCAn8wYahkYoPH_rgEogBkwKDg7QIqyP0iA-QRQXlg8Ag': '366999712',
AUCAn8wYah4aCTAwMjQ0MjAwMCiAGTAoODtAirI_SID5BFBeWDyl: '002442000',
};
for (const [code, id] of Object.entries(before)) {
assert.equal(encodeShareView(vesselView(id)), code);
const { state } = decodeOk(code);
assert.deepEqual(state.view, {
kind: 'thing',
entity: { kind: 'vessel', id },
offset: VESSEL_OFFSET,
lastKnown: VESSEL_FIX,
});
}
// A camera-only vessel view with a short text id, as before.
const tmp = 'ARCgnAEYwLgCagcaBXRtcC03GQ';
assert.equal(
encodeShareView({
lat: 1,
lon: 2,
view: { kind: 'thing', entity: { kind: 'vessel', id: 'tmp-7' } },
}),
tmp,
);
assert.equal(decodeOk(tmp).state.view.entity.id, 'tmp-7');
});
test('a UUID vessel id with another id, or of the wrong length, is malformed', () => {
const uuid = VESSEL_UUID.replaceAll('-', '')
.match(/../g)
.map((h) => parseInt(h, 16));
const reason = (code) => decodeShareView(code, { nowMs: NOW_MS }).reason;
// Two entity ids: the UUID and an MMSI, or an aircraft's address.
assert.equal(
reason(thingCode((t) => t.uintField(3, 366999712).bytesField(13, uuid))),
'duplicate-field',
);
assert.equal(
reason(thingCode((t) => t.uintField(1, 0xa1b2c3).bytesField(13, uuid))),
'duplicate-field',
);
assert.equal(
reason(thingCode((t) => t.bytesField(13, uuid).bytesField(13, uuid))),
'duplicate-field',
);
for (const bytes of [uuid.slice(0, 15), [...uuid, 0], []])
assert.equal(
reason(thingCode((t) => t.bytesField(13, bytes))),
'bad-length',
);
// The field is LEN only.
assert.equal(
reason(thingCode((t) => t.uintField(13, 5))),
'wire-type-mismatch',
);
// The 16 bytes alone are a vessel; any bytes spell a UUID.
assert.deepEqual(
decodeOk(thingCode((t) => t.bytesField(13, new Array(16).fill(0xff)))).state
.view.entity,
{ kind: 'vessel', id: 'ffffffff-ffff-ffff-ffff-ffffffffffff' },
);
});
test('a decoder that predates UUID vessel ids opens the view at its derived camera', () => {
// Such a decoder skips thing field 13 as it skips any unknown field: the
// same payload with the id under a field this decoder does not know.
const bytes = [...payloadBytes(encodeShareView(vesselView(VESSEL_UUID)))];
const at = bytes.length - 18;
assert.equal(bytes[at], 0x6a);
bytes[at] = 0xf2; // field 30, LEN
bytes.splice(at + 1, 0, 0x01);
// The thing message grew by a byte: its own length too.
const thingLength = bytes.indexOf(0x6a) + 1;
bytes[thingLength] += 1;
const result = decodeOk(hashOf(bytes));
assert.equal(result.fallback, 'unknown-entity-kind');
assert.deepEqual(result.state.view, { kind: 'place' });
const camera = followCameraFor(VESSEL_FIX, VESSEL_OFFSET);
assert.equal(result.state.lat, Math.round(camera.lat * 1e4) / 1e4);
assert.equal(result.state.lon, Math.round(camera.lon * 1e4) / 1e4);
});
test('the follow offset for a camera is the inverse of followCameraFor', () => {
const fix = { lat: 51.9301, lon: 4.0512, alt: 47 };
for (const offset of [
{ range: 3200, heading: 20, pitch: -30 },
{ range: 250, heading: 300, pitch: -5 },
{ range: 80_000, heading: 135, pitch: -70 },
{ range: 2_000_000, heading: 0, pitch: -89 },
])
assert.deepEqual(
followOffsetFor(fix, followCameraFor(fix, offset)),
offset,
);
// The codec carries ranges up to the bound a subject is checked against.
const far = {
range: SHARE_VIEW_FOLLOW_RANGE_MAX_M + 5,
heading: 0,
pitch: -35,
};
assert.equal(
normalizeShareViewState({
view: { kind: 'thing', entity: { kind: 'vessel', id: '1' }, offset: far },
}).view.offset.range,
SHARE_VIEW_FOLLOW_RANGE_MAX_M,
);
// A camera below the entity's horizontal, or at it, has no offset.
assert.equal(
followOffsetFor(fix, { lat: 51.95, lon: 4.0512, alt: 40 }),
null,
);
assert.equal(followOffsetFor(fix, fix), null);
// Far round the curve of the earth at low height: below its horizon.
assert.equal(
followOffsetFor(fix, { lat: 53.9, lon: 4.0512, alt: 1000 }),
null,
);
});
+8 -2
View File
@@ -8,6 +8,11 @@
* getTrack(reference, { signal }). References are opaque to consumers.
* Snapshots describe coverage and completeness independently of freshness.
* Records contain observation data only, with no scene objects or transport data.
* Every record has a stable `id` (a non-empty string) that keys it across
* snapshots. A vessel record's `id` is whatever its source gives, such as a
* provider id; its `mmsi` is an optional attribute ('' or absent when
* unknown), never its key, since vessels can share an MMSI. A source that
* only has MMSIs uses the MMSI as the id.
*
* A source may also declare a timeline (see readSnapshotTimeline): it can
* answer a snapshot at a given time, so the layer follows the world clock.
@@ -155,8 +160,9 @@ export function httpError(response, source) {
* malformed: `{ coverage(), stepMs, subscribe? }`. `coverage()` returns the
* span `{start, end}` (epoch ms) the source can answer a snapshot for, or
* null while it does not know yet; `stepMs` is the spacing of its snapshots;
* the optional `subscribe(listener)` tells of a change in coverage and
* returns the unsubscribe.
* the optional `subscribe(listener)` tells of a change in coverage, or of a
* better answer for a time already answered (a paused layer then loads the
* time shown again), and returns the unsubscribe.
* @param {object|null|undefined} source
* @returns {{coverage: () => ({start: number, end: number}|null),
* stepMs: number, subscribe: ((listener: () => void) => () => void)|null}|null}
+61
View File
@@ -9,6 +9,7 @@ import {
openSkySnapshot,
readsbSnapshot,
readSnapshotTimeline,
vesselSnapshot,
} from './index.js';
const now = 1800000000000;
@@ -196,6 +197,7 @@ test('vessel identity, opaque reference and heading/course remain distinct, unkn
'opaque-reference',
);
assert.equal(row.id, '123456789');
assert.equal(row.mmsi, '123456789');
assert.equal(row.reference, 'opaque-reference');
assert.equal(row.headingDeg, 45);
assert.equal(row.courseDeg, 60);
@@ -525,3 +527,62 @@ test('a source declares a timeline only with a coverage reader and a positive st
stop();
assert.equal(listeners.length, 0);
});
test('vessel snapshots key rows by the id idFor gives; the MMSI is an attribute', () => {
const at = { lat: 30, lon: -97 };
const rows = [
{ uuid: 'hull-a', mmsi: '123456789', ...at },
{ uuid: 'hull-b', mmsi: '123456789', ...at },
{ uuid: 'hull-c', mmsi: 'not-an-mmsi', ...at },
{ uuid: 'hull-a', mmsi: '123456789', ...at },
];
const keyed = vesselSnapshot(
{ rows },
{ idFor: (row) => row.uuid || row.mmsi },
);
assert.deepEqual(
keyed.records.map(({ id, mmsi, reference }) => [id, mmsi, reference]),
[
['hull-a', '123456789', 'hull-a'],
['hull-b', '123456789', 'hull-b'],
['hull-c', '', 'hull-c'],
],
);
assert.equal(keyed.rejectedCount, 1, 'a repeated id is one vessel');
// AISStream rows have only MMSIs: one MMSI is one vessel, as before.
const ais = vesselSnapshot({ rows });
assert.deepEqual(
ais.records.map(({ id, mmsi, reference }) => [id, mmsi, reference]),
[
['123456789', '123456789', '123456789'],
['not-an-mmsi', '', 'not-an-mmsi'],
],
);
const referenced = vesselSnapshot(
{ rows: rows.slice(0, 1) },
{ idFor: (row) => row.uuid, referenceFor: (row) => `track:${row.uuid}` },
);
assert.equal(referenced.records[0].reference, 'track:hull-a');
});
test('a vessel with no name is not named by its id; a ten-digit MMSI is an MMSI', () => {
const at = { lat: 30, lon: -97 };
const [unnamed, longMmsi, named] = vesselSnapshot(
{
rows: [
{ uuid: 'f00dcafe-0000-0000-0000-000000000001', ...at },
{ uuid: 'hull-x', mmsi: '1234567890', ...at },
{ uuid: 'hull-y', mmsi: '244123456', name: 'EVER GIVEN', ...at },
],
},
{ idFor: (row) => row.uuid },
).records;
assert.equal(unnamed.name, '');
assert.equal(unnamed.mmsi, '');
assert.deepEqual(
[longMmsi.mmsi, longMmsi.name],
['1234567890', '1234567890'],
);
assert.equal(named.name, 'EVER GIVEN');
});
+47 -10
View File
@@ -1,18 +1,41 @@
import { coordinates, epoch, finite, LiveSourceError } from './contract.js';
/** AIS positions are sea-surface observations; heading and course are distinct. */
export function normalizeVesselObservation(row, reference = null) {
const id = String(row?.mmsi || row?.input_identifier || '').trim();
/** An MMSI is up to ten digits (AIS carries it in 30 bits); anything else a
* row names it is not one. */
const MMSI_PATTERN = /^\d{1,10}$/;
/** The row's MMSI when it carries one that looks like an MMSI, else ''. */
function vesselMmsi(row) {
const text = String(row?.mmsi ?? '').trim();
return MMSI_PATTERN.test(text) ? text : '';
}
/** A row's stable id when its source names none: its MMSI or identifier. */
function defaultVesselId(row) {
return String(row?.mmsi || row?.input_identifier || '');
}
/**
* AIS positions are sea-surface observations; heading and course are distinct.
* `id` is the vessel's key; an MMSI is not one (vessels share numbers and a
* number can move to another hull), so `mmsi` is an attribute, '' when the
* row has none. A row with no id of its own is keyed by its MMSI.
*/
export function normalizeVesselObservation(row, reference = null, id = null) {
const key = String(id ?? defaultVesselId(row)).trim();
const latitude = finite(row?.lat),
longitude = finite(row?.lon);
if (!id || !coordinates(latitude, longitude)) return null;
if (!key || !coordinates(latitude, longitude)) return null;
const mmsi = vesselMmsi(row);
const speedKts = finite(row.speed);
return {
id,
reference: reference ?? id,
id: key,
mmsi,
reference: reference ?? key,
latitude,
longitude,
name: String(row.name || row.input_name || id),
// A source's own id is not a name: a vessel with none is unnamed.
name: String(row.name || row.input_name || mmsi || defaultVesselId(row)),
imo: String(row.imo || ''),
type: String(row.type_specific || row.type || ''),
destination: String(row.destination || ''),
@@ -26,12 +49,21 @@ export function normalizeVesselObservation(row, reference = null) {
};
}
/**
* A vessel snapshot from a `{ rows }` payload. `idFor(row)` gives a row's
* stable id (its MMSI or identifier by default); rows are deduped by that id,
* so two vessels sharing an MMSI stay two vessels. A source whose rows carry
* an id of their own passes it, such as `idFor: (row) => row.uuid || row.mmsi`.
* `referenceFor(row)` gives the reference its track is asked for by; the id
* by default.
*/
export function vesselSnapshot(
payload,
{
source = 'AISStream',
coverage = 'received AIS positions',
referenceFor = (row) => String(row.mmsi || row.input_identifier || ''),
idFor = defaultVesselId,
referenceFor = null,
} = {},
) {
if (!Array.isArray(payload?.rows))
@@ -40,8 +72,13 @@ export function vesselSnapshot(
const records = [],
ids = new Set();
for (const row of rows) {
const record = normalizeVesselObservation(row);
if (record) record.reference = referenceFor(row);
const record = normalizeVesselObservation(
row,
null,
row && typeof row === 'object' ? idFor(row) : null,
);
// A row's track is asked for by its id unless the source says otherwise.
if (record && referenceFor) record.reference = referenceFor(row);
if (record && !ids.has(record.id)) {
records.push(record);
ids.add(record.id);
+14 -6
View File
@@ -279,6 +279,13 @@ export function createSnapshotTimeline({
} else keepUp(timeMs, state);
}
/** Load the time shown afresh. */
function reloadShown() {
if (!started || clock.isLive()) return;
failedUntil = -Infinity;
jumpTo(clock.getState().timeMs);
}
return {
/** The source's timeline while the layer follows the clock, else null. */
get timeline() {
@@ -304,16 +311,17 @@ export function createSnapshotTimeline({
clock.register({ id, coverage: () => span() }),
clock.subscribe(follow),
];
const unsubscribe = timeline.subscribe?.(() => clock.coverageChanged());
// A source tells of new coverage, or of a better answer for times it
// has answered; paused, a time already shown is loaded again.
const unsubscribe = timeline.subscribe?.(() => {
clock.coverageChanged();
if (!clock.isPlaying() && lead !== null && !inflight) reloadShown();
});
if (typeof unsubscribe === 'function') releases.push(unsubscribe);
follow(clock.getState());
},
/** Load the time shown afresh, as when the area the layer asks for moved. */
reload() {
if (!started || clock.isLive()) return;
failedUntil = -Infinity;
jumpTo(clock.getState().timeMs);
},
reload: () => reloadShown(),
/** The layer is off: stop following and drop what loads. */
stop() {
if (!started) return;
+32
View File
@@ -302,3 +302,35 @@ test('a layer never has two loads out: the older is aborted before a newer start
h.follower.stop();
assert.equal(live(), 0);
});
test('a source that tells of a better answer has the paused time loaded again, not a playing one', async () => {
const h = harness();
h.follower.start();
const at = WALL - 600_000;
h.clock.seek(at);
await flush();
await h.answer();
const before = loads(h.calls).length;
for (const listener of h.coverageListeners) listener();
await flush();
assert.equal(loads(h.calls).length, before + 1);
assert.equal(loads(h.calls).at(-1).atMs, at);
await h.answer(h.pending.length - 1);
h.clock.play();
const playing = loads(h.calls).length;
for (const listener of h.coverageListeners) listener();
await flush();
assert.ok(
loads(h.calls)
.slice(playing)
.every((call) => call.atMs !== at),
'playing, the time shown is not loaded again',
);
h.clock.goLive();
await flush();
const live = loads(h.calls).length;
for (const listener of h.coverageListeners) listener();
await flush();
assert.equal(loads(h.calls).length, live, 'live, nothing is loaded');
h.follower.stop();
});
+36 -10
View File
@@ -28,6 +28,14 @@ const MMSI_SCHEMA = Object.freeze({
pattern: '^\\d{5,10}$',
description: 'Maritime Mobile Service Identity, such as "366999712".',
});
const VESSEL_ID_SCHEMA = Object.freeze({
type: 'string',
minLength: 1,
maxLength: 128,
description:
'The vessel id a vessel search returned. Vessels can share an MMSI; ' +
'the id names one of them.',
});
const MAX_TRACK_POINTS = 200;
const ALL_VESSELS = 1_000_000;
@@ -47,10 +55,13 @@ const vesselDestination = (value) => {
return destination && /\p{L}/u.test(destination) ? destination : null;
};
// `id` is the vessel's key; `mmsi` is its MMSI when known (vessels can share
// one), else null.
function vesselRow(record, from) {
const point = { lat: record.latitude, lon: record.longitude };
return {
mmsi: record.id,
id: record.id,
mmsi: text(record.mmsi),
name: text(record.name),
imo: text(record.imo),
type: vesselType(record.type),
@@ -194,7 +205,7 @@ export const findVessel = defineTool({
const [key] = given;
const wanted = args[key].trim().toUpperCase();
const matches = {
mmsi: (record) => record.id === wanted,
mmsi: (record) => String(record.mmsi ?? '') === wanted,
imo: (record) => String(record.imo).replace(/^IMO/i, '') === wanted,
name: (record) => String(record.name).toUpperCase().includes(wanted),
}[key];
@@ -227,25 +238,40 @@ export const getVesselTrack = defineTool({
name: 'get_vessel_track',
title: 'Vessel track',
description:
'Recent positions of one ship by MMSI, oldest first. Long tracks are ' +
`evenly thinned to at most ${MAX_TRACK_POINTS} points.`,
'Recent positions of one ship by its vessel id or MMSI, oldest first. ' +
`Long tracks are evenly thinned to at most ${MAX_TRACK_POINTS} points.`,
inputSchema: {
type: 'object',
properties: { mmsi: MMSI_SCHEMA },
required: ['mmsi'],
properties: { id: VESSEL_ID_SCHEMA, mmsi: MMSI_SCHEMA },
additionalProperties: false,
},
requires: ['vessels'],
async run(args, { services, signal }) {
const track = await services.vessels.getTrack(args.mmsi, { signal });
const given = ['id', 'mmsi'].filter((key) => args[key] != null);
if (given.length !== 1)
throw new ToolError(
'invalid_arguments',
'Give exactly one of id or mmsi',
);
const [key] = given;
const wanted = String(args[key]).trim();
// A track is asked for by the source's reference for the vessel, which
// for an id is the reported vessel's; an MMSI is passed as it is.
let reference = wanted;
if (key === 'id') {
const snapshot = await readVessels(services, signal);
const record = snapshot.records.find((item) => item.id === wanted);
reference = record?.reference ?? wanted;
}
const track = await services.vessels.getTrack(reference, { signal });
const points = track.records
.filter((point) => Number.isFinite(point.observedAtMs))
.sort((a, b) => a.observedAtMs - b.observedAtMs);
const kept = thinEvenly(points, MAX_TRACK_POINTS);
return {
summary: points.length
? `${countNoun(points.length, 'position')} for ${args.mmsi} from ${isoTime(points[0].observedAtMs)} to ${isoTime(points.at(-1).observedAtMs)}.`
: `No recent track is available for ${args.mmsi}.`,
? `${countNoun(points.length, 'position')} for ${wanted} from ${isoTime(points[0].observedAtMs)} to ${isoTime(points.at(-1).observedAtMs)}.`
: `No recent track is available for ${wanted}.`,
data: {
view: kept.length
? suggestView(services, {
@@ -254,7 +280,7 @@ export const getVesselTrack = defineTool({
altitudeM: 20_000,
})
: null,
mmsi: args.mmsi,
...(key === 'id' ? { id: wanted } : { mmsi: wanted }),
total: points.length,
returned: kept.length,
thinned: kept.length < points.length,
+50
View File
@@ -5,8 +5,10 @@ import { LiveSourceError } from '../../sources/live/contract.js';
import { createVesselSource } from '../../sources/live/standalone.js';
import { AISSTREAM_CACHE_MAX } from '../../../server/providers/vessels/ais-store.js';
// An AIS source's vessel: keyed by its MMSI, which it also reports.
const vessel = (id, latitude, longitude, extra = {}) => ({
id,
mmsi: id,
reference: id,
latitude,
longitude,
@@ -79,6 +81,7 @@ test('vessels in an area are nearest first and filterable by type', async () =>
['366999712', '538001234'],
);
assert.deepEqual(all.data.rows[0], {
id: '366999712',
mmsi: '366999712',
name: 'GOLDEN BEAR',
imo: '9123456',
@@ -164,6 +167,53 @@ test('vessel tracks are ordered and drop untimed positions', async () => {
);
});
test('vessels sharing an MMSI are found as two, each by its own id', async () => {
const shared = [
vessel('hull-a', 37.81, -122.41, { mmsi: '366999712', name: 'A' }),
vessel('hull-b', 37.79, -122.42, {
mmsi: '366999712',
name: 'B',
reference: 'track:hull-b',
}),
vessel('hull-c', 37.8, -122.4, { mmsi: undefined, name: 'C' }),
];
const tracks = [];
const catalog = composeCatalog({
tools: coreTools,
services: {
vessels: {
getSnapshot: async () => ({ records: shared, freshness: 'current' }),
getTrack: async (reference) => {
tracks.push(reference);
return { records: [] };
},
},
},
});
const found = await catalog.call('find_vessel', { mmsi: '366999712' });
assert.deepEqual(
found.data.rows.map((row) => [row.id, row.mmsi]),
[
['hull-a', '366999712'],
['hull-b', '366999712'],
],
);
const area = await catalog.call('vessels_in_area', { area: bay });
assert.deepEqual(
area.data.rows.find((row) => row.id === 'hull-c').mmsi,
null,
'a vessel with no MMSI says so',
);
const track = await catalog.call('get_vessel_track', { id: 'hull-b' });
assert.deepEqual(tracks, ['track:hull-b'], "asked by the vessel's reference");
assert.equal(track.data.id, 'hull-b');
assert.equal(track.summary, 'No recent track is available for hull-b.');
await assert.rejects(
catalog.call('get_vessel_track', { id: 'hull-b', mmsi: '366999712' }),
/exactly one/,
);
});
test('a missing AIS key surfaces as an unavailable feed', async () => {
const keyless = {
getSnapshot: async () => {
+6 -1
View File
@@ -472,7 +472,12 @@ export class StyleManager extends ShellFacade {
initialShareCode,
});
this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) =>
readShareFollowSubject({ viewer: this.viewer, nowMs }),
readShareFollowSubject({
viewer: this.viewer,
nowMs,
readSelectedVessel: () =>
this.services.aisLiveVesselsLayer?.getSelectedFix?.() ?? null,
}),
);
this.shareLinkManager.setPanelStateProvider(() =>
this._buildSharePanelState(),
+15 -9
View File
@@ -15,6 +15,7 @@ import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js';
import { VesselRecords } from '../layers/vessels/records.js';
const RECORD = Object.freeze({
id: '211000000',
mmsi: '211000000',
name: 'NORDIC STAR',
type: '70',
@@ -65,9 +66,9 @@ function vesselLayer(record) {
enabled && selected
? { ...vesselCockpitInfo(selected), seaSurfaceHeightM: 0 }
: null,
setCockpitSubject(mmsi) {
calls.push(['subject', mmsi]);
subject = mmsi;
setCockpitSubject(id) {
calls.push(['subject', id]);
subject = id;
return true;
},
// Like the layer, leaves out the ship whose bridge the Cockpit rides.
@@ -75,7 +76,7 @@ function vesselLayer(record) {
radarQueries.push({ lat, lon, rangeM, maxCount });
const own = {
...NEIGHBOUR,
id: record.mmsi,
id: record.id,
latitude: record.lat,
longitude: record.lon,
};
@@ -257,7 +258,7 @@ test('entering the bridge hides the vessel and shows bridge readouts', async (t)
await settle();
assert.equal(cockpit.active, true);
assert.equal(cockpit.subjectKind, 'vessel');
assert.deepEqual(vessels.calls, [['subject', RECORD.mmsi]]);
assert.deepEqual(vessels.calls, [['subject', RECORD.id]]);
assert.equal(document.body.classList.contains('cockpit-mode'), true);
assert.equal(
@@ -303,7 +304,7 @@ test('exiting the bridge releases the vessel and returns to its map view', async
assert.equal(cockpit.active, false);
assert.equal(cockpit.subjectKind, null);
assert.deepEqual(vessels.calls, [
['subject', RECORD.mmsi],
['subject', RECORD.id],
['subject', null],
['focus'],
]);
@@ -374,7 +375,7 @@ test('tracking an aircraft from the bridge ends it without refocusing the vessel
assert.equal(viewer.trackedEntity, aircraft, 'the aircraft keeps tracking');
assert.equal(aircraft.show, true, 'the aircraft was never adopted');
assert.deepEqual(vessels.calls, [
['subject', RECORD.mmsi],
['subject', RECORD.id],
['subject', null],
]);
});
@@ -1474,7 +1475,7 @@ test('a fix the feed keeps re-serving goes on ageing into a stale feed', async (
effects,
);
poll();
const record = records.byMmsi.get(RECORD.mmsi);
const record = records.byId.get(RECORD.id);
const { $, cockpit, shots } = fixture(t, { record });
cockpit.enter();
await settle();
@@ -1540,7 +1541,12 @@ test("a ship's double-click memory does not carry over to another ship", async (
heightSensitiveProjection(viewer);
cockpit.onScenePointerDown(1000);
// Within the memory another ship is selected, drawn elsewhere.
vessels.select({ ...RECORD, mmsi: '211000009', name: 'OTHER' });
vessels.select({
...RECORD,
id: '211000009',
mmsi: '211000009',
name: 'OTHER',
});
heightSensitiveProjection(viewer, { x: 900 });
assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }, 1250), false);
assert.equal(cockpit.active, false);
+1 -1
View File
@@ -164,7 +164,7 @@ export function maybeRefreshRegionalBrief(info) {
!Number.isFinite(info.longitude)
)
return;
const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.mmsi || info.noradId || info.registration || info.callsign || 'unknown'}`;
const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || (info.kind === 'vessel' ? info.id : '') || info.noradId || info.registration || info.callsign || 'unknown'}`;
if (subjectId !== this.regionalBriefSubjectId) {
this.regionalBriefAbort?.abort();
this.regionalBriefAbort = null;
+3 -3
View File
@@ -128,7 +128,7 @@ function vesselSurfaceM(info, nowMs, at = info) {
: 0;
const scene = this.viewer.scene;
let read =
this.vesselWaterline?.mmsi === info.mmsi ? this.vesselWaterline : null;
this.vesselWaterline?.vesselId === info.id ? this.vesselWaterline : null;
if (
typeof scene?.sampleHeightMostDetailed === 'function' &&
(!read || nowMs - read.sampledMs >= VESSEL_WATERLINE_REFRESH_MS)
@@ -138,7 +138,7 @@ function vesselSurfaceM(info, nowMs, at = info) {
);
if (!read) {
read = {
mmsi: info.mmsi,
vesselId: info.id,
heightM: loadedWaterlineM(scene, probes, seaM),
sampledMs: nowMs,
// Whether a full-detail read has answered for this ship.
@@ -219,7 +219,7 @@ function updateVessel(nowMs) {
return;
}
const info = this.readVesselInfo();
if (!info || info.mmsi !== this.subjectId) {
if (!info || info.id !== this.subjectId) {
this.exit({ restoreTracking: false });
return;
}
+1 -1
View File
@@ -139,7 +139,7 @@ export class CockpitViewController {
*/
this.subjectKind = null;
/**
* The vessel subject's MMSI or the satellite's NORAD id; aircraft are
* The vessel subject's id or the satellite's NORAD id; aircraft are
* identified by trackedEntity.
*/
this.subjectId = null;
+1 -1
View File
@@ -255,7 +255,7 @@ export class CockpitCoordinator {
: vessel
? {
kind: 'vessel',
id: vessel.mmsi,
id: vessel.id,
layerId: vessel.layerId,
callsign: null,
name: vessel.name,
+5 -3
View File
@@ -83,8 +83,10 @@ export function onScenePointerDown(nowMs = performance.now()) {
return;
}
const at = subjectCanvasPosition.call(this);
const mmsi = this.readVesselInfo()?.mmsi ?? null;
this.doubleClickStart = at ? { x: at.x, y: at.y, atMs: nowMs, mmsi } : null;
const vesselId = this.readVesselInfo()?.id ?? null;
this.doubleClickStart = at
? { x: at.x, y: at.y, atMs: nowMs, vesselId }
: null;
}
/**
@@ -111,7 +113,7 @@ export function onSceneDoubleClick(position, nowMs = performance.now()) {
// The memory is the ship's it was taken for: one followed since (an
// aircraft, another ship) is hit only where it is drawn.
this.resolveEntryKind() === 'vessel' &&
start.mmsi === (this.readVesselInfo()?.mmsi ?? null) &&
start.vesselId === (this.readVesselInfo()?.id ?? null) &&
doubleClickHitsSubject(position, start));
if (!hit) return false;
this.enter();
+8 -3
View File
@@ -65,7 +65,12 @@ export function resolveEntryKind() {
export function dispatchCockpitModeChanged(active, info = null) {
const subjectId = active
? String(info?.icao24 || info?.mmsi || info?.noradId || '')
? String(
info?.icao24 ||
(info?.kind === 'vessel' ? info.id : '') ||
info?.noradId ||
'',
)
.trim()
.toLowerCase() || null
: null;
@@ -245,7 +250,7 @@ export function enter({ kind = null } = {}) {
// Each session reads its own water: never a height left from the last.
this.vesselWaterline = null;
this.vesselFix = null;
this.subjectId = vessel ? info.mmsi : satellite ? info.noradId : null;
this.subjectId = vessel ? info.id : satellite ? info.noradId : null;
this.trackedEntity = entity;
if (entity) {
this.trackedEntityWasShown = entity.show;
@@ -255,7 +260,7 @@ export function enter({ kind = null } = {}) {
this.services.satellitesLayer?.setCockpitSubject?.(info.noradId);
this.beginOrbital();
} else {
this.services.aisLiveVesselsLayer?.setCockpitSubject?.(info.mmsi);
this.services.aisLiveVesselsLayer?.setCockpitSubject?.(info.id);
}
// A ship reporting neither heading nor course keeps the camera's own
// heading: no direction is made up for it.
+59 -6
View File
@@ -2,6 +2,8 @@ import * as Cesium from 'cesium';
import { trackedDisplayPositionForCamera } from '../data/trackedCamera.js';
import {
SHARE_VIEW_ENTITY_KINDS,
SHARE_VIEW_FOLLOW_RANGE_MAX_M,
followOffsetFor,
followOffsetFromEnu,
followOffsetToEnu,
} from '../share/follow.js';
@@ -28,17 +30,68 @@ function enuOffset(origin, position) {
return { east: local.x, north: local.y, up: local.z };
}
/** Degrees to the share format's 10⁻⁴-degree coordinate precision. */
const toCoordinate = (radians) =>
Math.round(Cesium.Math.toDegrees(radians) * 1e4) / 1e4;
/**
* The entity the camera follows as `{ kind, id, offset, lastKnown }`, with
* the camera's offset from it and its displayed position as of `nowMs`; null
* when the camera follows nothing a share link can name. A selected vessel is
* not followed (the camera stays where the user puts it), so a link made then
* keeps its own camera.
* The selected vessel as a thing view's subject: its drawn position as the
* last-known fix, and the follow offset that puts the derived camera
* (followCameraFor) where the camera is now. The offset is computed from the
* fix at the format's precision, so the opened link's camera matches this
* one to within the offset's whole metres and degrees. Null when the offset
* cannot carry the camera (it is not above the vessel's horizon, or its range
* is outside 1 m to SHARE_VIEW_FOLLOW_RANGE_MAX_M): the link is then a place
* view.
*/
export function readShareFollowSubject({ viewer, nowMs = Date.now() }) {
function selectedVesselSubject(cameraPosition, vessel, nowMs) {
const id = String(vessel?.id || '');
if (!id || !vessel.position) return null;
const fixAt = Cesium.Cartographic.fromCartesian(vessel.position);
const cameraAt = Cesium.Cartographic.fromCartesian(cameraPosition);
if (!fixAt || !cameraAt) return null;
const lastKnown = {
lat: toCoordinate(fixAt.latitude),
lon: toCoordinate(fixAt.longitude),
alt: Math.round(fixAt.height),
observedAtMs: Number.isFinite(vessel.observedAtMs)
? vessel.observedAtMs
: nowMs,
};
const offset = followOffsetFor(lastKnown, {
lat: Cesium.Math.toDegrees(cameraAt.latitude),
lon: Cesium.Math.toDegrees(cameraAt.longitude),
alt: cameraAt.height,
});
if (
!offset ||
!(offset.range >= 1 && offset.range <= SHARE_VIEW_FOLLOW_RANGE_MAX_M)
)
return null;
return { kind: 'vessel', id, offset, lastKnown };
}
/**
* The entity a share link made now names, as `{ kind, id, offset, lastKnown }`:
* the entity the camera follows, with the camera's offset from it and its
* displayed position as of `nowMs`; else, while the camera follows nothing,
* the selected vessel (`readSelectedVessel()`, the vessel layer's
* `getSelectedFix()`). A selected vessel is not followed (the camera stays
* where the user puts it), so its offset is the one that derives the camera
* where it is now. Null when there is nothing a share link can name.
*/
export function readShareFollowSubject({
viewer,
nowMs = Date.now(),
readSelectedVessel = null,
}) {
const cameraPosition = viewer?.camera?.positionWC;
if (!cameraPosition) return null;
const tracked = viewer.trackedEntity;
if (!tracked) {
const vessel = readSelectedVessel?.() ?? null;
return vessel ? selectedVesselSubject(cameraPosition, vessel, nowMs) : null;
}
const trackedId = String(tracked?.gevTrackedId || '');
const separator = trackedId.indexOf(':');
if (separator <= 0) return null;
+218 -10
View File
@@ -12,6 +12,7 @@ import {
} from './shareFollow.js';
import { createDefaultLayerState } from '../data/layerState.js';
import { parseSharePath, shareLinkUrl } from '../share/shareLinkPath.js';
import { worldClock } from '../time/worldClock.js';
import {
decodeShareView,
encodeShareView,
@@ -234,7 +235,9 @@ test('a code that fails its check restores nothing and says so', async (t) => {
(await page.owner.initialRestorePromise).status,
'not-requested',
);
assert.deepEqual(page.notices, ["This share link can't be opened"]);
assert.deepEqual(page.notices, [
{ message: "This share link can't be opened" },
]);
// The default start's first address write leaves the bad link behind.
page.manager._updateHash();
assert.equal(page.location.pathname, '/');
@@ -334,9 +337,10 @@ test('a thing that is not reporting leaves the view at its last-known fix, with
await flush();
assert.equal(page.notices.length, 1);
assert.match(
page.notices[0],
/^A1B2C3 isn't reporting now — showing where it was at .+/,
page.notices[0].message,
/^The shared flight isn't reporting now — showing where it was at .+/,
);
assert.equal(page.notices[0].detail, 'ICAO: A1B2C3');
// A long link names no fix and keeps the existing notice.
page.owner._initialShareState = { shareView: null };
page.owner._handleShareTrackingRestoreStatus({
@@ -345,19 +349,48 @@ test('a thing that is not reporting leaves the view at its last-known fix, with
label: 'flight',
classification: 'expired',
});
assert.equal(page.notices[1], 'Shared flight follow expired');
assert.deepEqual(page.notices[1], {
message: 'Shared flight follow expired',
});
});
test('the not-reporting notice names the id and the time of the fix', () => {
test('the not-reporting notice is a sentence per kind with the id on a second line', () => {
const at = new Date(2026, 9, 26, 14, 5).getTime();
const flight = sharedFixNotice(
{ kind: 'aircraft', id: 'a1b2c3' },
at,
at + 60_000,
);
assert.match(
sharedFixNotice({ kind: 'aircraft', id: 'a1b2c3' }, at, at + 60_000),
/^A1B2C3 isn't reporting now — showing where it was at .*05/,
flight.message,
/^The shared flight isn't reporting now — showing where it was at .*05/,
);
assert.equal(flight.detail, 'ICAO: A1B2C3');
assert.deepEqual(
sharedFixNotice({ kind: 'military_aircraft', id: 'ae01ce' }, null),
{
message:
"The shared military flight isn't reporting now — showing where it was last seen",
detail: 'ICAO: AE01CE',
},
);
assert.deepEqual(sharedFixNotice({ kind: 'vessel', id: '366999712' }, null), {
message:
"The shared vessel isn't reporting now — showing where it was last seen",
detail: 'MMSI: 366999712',
});
assert.equal(
sharedFixNotice({ kind: 'vessel', id: '366999712' }, null),
"366999712 isn't reporting now — showing where it was last seen",
sharedFixNotice(
{ kind: 'vessel', id: '3F2504E0-4F89-11D3-9A0C-0305E82C3301' },
null,
).detail,
'Vessel UUID: 3f2504e0-4f89-11d3-9a0c-0305e82c3301',
);
assert.deepEqual(sharedFixNotice({ kind: 'satellite', id: '25544' }, null), {
message:
"The shared satellite isn't reporting now — showing where it was last seen",
detail: 'NORAD: 25544',
});
});
test('a shared vessel is selected once the vessel feed has it', async (t) => {
@@ -398,7 +431,11 @@ test('a shared vessel is selected once the vessel feed has it', async (t) => {
page.runAll();
}
assert.equal(await missing, false);
assert.match(page.notices.at(-1), /^366999712 isn't reporting now/);
assert.match(
page.notices.at(-1).message,
/^The shared vessel isn't reporting now/,
);
assert.equal(page.notices.at(-1).detail, 'MMSI: 366999712');
});
test('the share button links to /share/<code>, following the followed entity', (t) => {
@@ -552,3 +589,174 @@ test('long links and hash navigation are unaffected by a share code start', asyn
assert.equal(page.restores.length, 2);
assert.equal(page.restores[1].style, 'normal');
});
const VESSEL_UUID = '0770e3ca-b896-c307-f499-4f9df2b8f86b';
const VESSEL_AT = { lat: 51.93012, lon: 4.05118, alt: 46.6 };
/** A camera `offset` from the vessel, as Cesium places it (WGS84). */
function cameraAround(offset) {
const vessel = Cesium.Cartesian3.fromDegrees(
VESSEL_AT.lon,
VESSEL_AT.lat,
VESSEL_AT.alt,
);
const local = followOffsetToEnu(offset);
return {
vessel,
camera: Cesium.Matrix4.multiplyByPoint(
Cesium.Transforms.eastNorthUpToFixedFrame(vessel),
new Cesium.Cartesian3(local.east, local.north, local.up),
new Cesium.Cartesian3(),
),
};
}
test('a selected vessel is the subject when the camera follows nothing', (t) => {
installPage(t, 'https://gev.example/');
const offset = { range: 6400, heading: 250, pitch: -28 };
const { vessel, camera } = cameraAround(offset);
const viewer = fakeViewer();
viewer.camera.positionWC = camera;
const fixAtMs = Math.floor(NOW_MS / 1000) * 1000 - 90_000;
let selected = { id: VESSEL_UUID, position: vessel, observedAtMs: fixAtMs };
const readSelectedVessel = () => selected;
const subject = readShareFollowSubject({
viewer,
nowMs: NOW_MS,
readSelectedVessel,
});
assert.equal(subject.kind, 'vessel');
assert.equal(subject.id, VESSEL_UUID);
// The fix is the vessel's drawn position at the format's precision.
assert.deepEqual(subject.lastKnown, {
lat: 51.9301,
lon: 4.0512,
alt: 47,
observedAtMs: fixAtMs,
});
// The inverse of the derived camera, from the user's camera: within the
// offset's whole degrees of the camera that placed it.
// (The fix is rounded to 10⁻⁴°, about 11 m, before the offset is taken.)
assert.ok(Math.abs(subject.offset.range - offset.range) <= 20);
assert.ok(Math.abs(subject.offset.heading - offset.heading) <= 1);
assert.ok(Math.abs(subject.offset.pitch - offset.pitch) <= 1);
// The share button's link opens with its camera where this one is.
const manager = new ShareLinkManager(viewer);
manager.setFollowSubjectProvider(({ nowMs }) =>
readShareFollowSubject({ viewer, nowMs, readSelectedVessel }),
);
const href = manager.buildLink({ nowMs: NOW_MS });
const code = parseSharePath(new URL(href).pathname).code;
const { state } = decodeShareView(code, { nowMs: NOW_MS });
assert.deepEqual(state.view.entity, { kind: 'vessel', id: VESSEL_UUID });
assert.equal(state.view.lastKnown.observedAtMs, fixAtMs);
const opened = Cesium.Cartesian3.fromDegrees(state.lon, state.lat, state.alt);
const missM = Cesium.Cartesian3.distance(opened, camera);
// Whole degrees at 6.4 km are at most about 80 m; 10⁻⁴° is about 11 m.
assert.ok(missM < 100, `${missM.toFixed(1)} m from the camera`);
// The UUID costs 13 bytes more than an ICAO address: 16 against 3.
manager.setFollowSubjectProvider(() => ({
...subject,
kind: 'aircraft',
id: 'a1b2c3',
}));
const aircraft = parseSharePath(
new URL(manager.buildLink({ nowMs: NOW_MS })).pathname,
).code;
assert.ok(
code.length - aircraft.length <= 18,
`${code.length} against ${aircraft.length} characters`,
);
manager.destroy();
// A camera below the vessel's horizon cannot be carried: a place view.
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(4.0512, 54, 900);
assert.equal(
readShareFollowSubject({ viewer, nowMs: NOW_MS, readSelectedVessel }),
null,
);
// Nor can one at the vessel.
viewer.camera.positionWC = vessel;
assert.equal(
readShareFollowSubject({ viewer, nowMs: NOW_MS, readSelectedVessel }),
null,
);
// With nothing selected there is no subject.
viewer.camera.positionWC = camera;
selected = null;
assert.equal(
readShareFollowSubject({ viewer, nowMs: NOW_MS, readSelectedVessel }),
null,
);
});
test('a followed aircraft or satellite keeps priority over a selected vessel', () => {
const { vessel, camera } = cameraAround({
range: 2400,
heading: 30,
pitch: -20,
});
const readSelectedVessel = () => ({
id: VESSEL_UUID,
position: vessel,
observedAtMs: NOW_MS,
});
const viewer = {
camera: { positionWC: camera },
clock: { currentTime: Cesium.JulianDate.now() },
trackedEntity: {
gevTrackedId: 'satellites:25544',
gevDisplayPosition: () => vessel,
},
};
const subject = readShareFollowSubject({ viewer, readSelectedVessel });
assert.equal(subject.kind, 'satellite');
// A followed entity the share format cannot name is still followed: the
// link is a place view, not the selected vessel.
viewer.trackedEntity.gevTrackedId = 'local-adsb:abc';
assert.equal(readShareFollowSubject({ viewer, readSelectedVessel }), null);
});
test('a link to a UUID vessel at a world time selects it once its snapshot has it', async (t) => {
t.after(() => worldClock.goLive());
const worldTime = Math.floor((NOW_MS - 3_600_000) / 1000) * 1000;
const code = encodeShareView({
view: {
kind: 'thing',
entity: { kind: 'vessel', id: VESSEL_UUID },
offset: { range: 3200, heading: 20, pitch: -30 },
lastKnown: { ...VESSEL_AT, alt: 47, observedAtMs: worldTime },
},
sharedAtMs: Math.floor(NOW_MS / 1000) * 1000,
worldTime,
});
const page = openPage(t, `http://localhost/share/${code}`);
// The link opens paused at its world time.
assert.equal(worldClock.isLive(), false);
assert.equal(worldClock.now(), worldTime);
// The vessel layer: its records are the snapshot at the clock's time, which
// arrives a moment after the layer turns on.
const records = new Set();
const selected = [];
page.owner._dataManager = {
layers: new Map([
[
'ais-live-vessels',
{
module: {
hasContact: (id) => (records.size ? records.has(id) : null),
selectById: (id) => records.has(id) && selected.push(id) > 0,
},
},
],
]),
};
await restore(page);
assert.deepEqual(selected, []);
if (worldClock.now() === worldTime) records.add(VESSEL_UUID);
page.runAll();
await flush();
assert.deepEqual(selected, [VESSEL_UUID]);
assert.deepEqual(page.notices, []);
});
+88 -24
View File
@@ -70,34 +70,88 @@ export function shareTrackingRestoreNotice(result) {
: t('Shared {subject} is unavailable', { subject });
}
// The not-reporting notice, one ready-made sentence per shared entity kind
// (SHARE_VIEW_ENTITY_KINDS) for the same reason as SHARE_RESTORE_NOTICES.
const SHARED_FIX_NOTICES = Object.freeze({
aircraft: Object.freeze({
lastSeen: msg(
"The shared flight isn't reporting now — showing where it was last seen",
),
at: msg(
"The shared flight isn't reporting now — showing where it was at {time}",
),
}),
military_aircraft: Object.freeze({
lastSeen: msg(
"The shared military flight isn't reporting now — showing where it was last seen",
),
at: msg(
"The shared military flight isn't reporting now — showing where it was at {time}",
),
}),
vessel: Object.freeze({
lastSeen: msg(
"The shared vessel isn't reporting now — showing where it was last seen",
),
at: msg(
"The shared vessel isn't reporting now — showing where it was at {time}",
),
}),
satellite: Object.freeze({
lastSeen: msg(
"The shared satellite isn't reporting now — showing where it was last seen",
),
at: msg(
"The shared satellite isn't reporting now — showing where it was at {time}",
),
}),
});
// The second line names the entity by the id the link carries.
function sharedFixDetail(entity) {
const id = String(entity.id);
switch (entity.kind) {
case 'aircraft':
case 'military_aircraft':
// ICAO addresses read as they are written on the aircraft: upper case.
return t('ICAO: {id}', { id: id.toUpperCase() });
case 'satellite':
return t('NORAD: {id}', { id });
case 'vessel':
return /^\d+$/.test(id)
? t('MMSI: {id}', { id })
: t('Vessel UUID: {id}', { id: id.toLowerCase() });
default:
return id;
}
}
/**
* The notice for a shared entity that is not reporting: the view shows where
* it was when the link was made.
* it was when the link was made. The sentence is the notice; the entity's id
* goes on a second line under it.
* @param {{kind: string, id: string}} entity
* @param {number|null} observedAtMs
* @param {number} [nowMs]
* @returns {string}
* @returns {{message: string, detail: string}}
*/
export function sharedFixNotice(entity, observedAtMs, nowMs = Date.now()) {
// ICAO addresses read as they are written on the aircraft: upper case.
const id =
ENTITY_BY_KIND.get(entity.kind)?.idForm === 'hex24'
? String(entity.id).toUpperCase()
: String(entity.id);
const detail = sharedFixDetail(entity);
const notices = SHARED_FIX_NOTICES[entity.kind];
if (!notices)
return {
message: t('Shared {subject} is unavailable', { subject: t('entity') }),
detail,
};
if (!Number.isFinite(observedAtMs))
return t("{id} isn't reporting now — showing where it was last seen", {
id,
});
return { message: t(notices.lastSeen), detail };
const time = formatDate(
observedAtMs,
nowMs - observedAtMs < DAY_MS
? { hour: '2-digit', minute: '2-digit' }
: { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' },
);
return t("{id} isn't reporting now — showing where it was at {time}", {
id,
time,
});
return { message: t(notices.at, { time }), detail };
}
/** Own initial share restoration, durable layer state and restoration notices. */
@@ -181,7 +235,7 @@ export class ShareRestoration {
this.syncShareState();
if (this.shareLinkManager?.rejectedShareCode)
this._presentAfterStartupCover(
t("This share link can't be opened"),
{ message: t("This share link can't be opened") },
++this._shareTrackingNoticeGeneration,
);
}
@@ -411,7 +465,7 @@ export class ShareRestoration {
this._presentAfterStartupCover(
view.lastKnown
? sharedFixNotice(view.entity, view.lastKnown.observedAtMs)
: t('Shared vessel is unavailable'),
: { message: t('Shared vessel is unavailable') },
++this._shareTrackingNoticeGeneration,
);
return false;
@@ -505,26 +559,36 @@ export class ShareRestoration {
// A share link that knows where the entity was says so; the view is
// already there.
const fix = this._sharedThingFor(result)?.lastKnown;
const message = fix
const notice = fix
? sharedFixNotice(
this._initialShareState.shareView.entity,
fix.observedAtMs,
)
: shareTrackingRestoreNotice(result);
: { message: shareTrackingRestoreNotice(result) };
if (this._resolveInitialShareRestore) {
void this.initialRestorePromise.then(() =>
this._presentAfterStartupCover(message, noticeGeneration),
this._presentAfterStartupCover(notice, noticeGeneration),
);
return;
}
this._presentAfterStartupCover(message, noticeGeneration);
this._presentAfterStartupCover(notice, noticeGeneration);
}
/**
* Show a status notice once the start-up cover is gone, unless a newer
* notice (generation) has taken its place.
* notice (generation) has taken its place. A notice with a detail line is
* a last-known one: the sentence over the entity's id.
* @param {{message: string, detail?: string}} notice
* @param {number} noticeGeneration
*/
_presentAfterStartupCover(message, noticeGeneration) {
_presentAfterStartupCover(notice, noticeGeneration) {
const show = () =>
notice.detail
? this.showStatus(notice.message, {
state: 'last-known',
detail: notice.detail,
})
: this.showStatus(notice.message);
const showAfterStartupCover = () => {
this._lifetime.frame(() => {
if (
@@ -540,7 +604,7 @@ export class ShareRestoration {
!startupCover ||
getComputedStyle(startupCover).visibility === 'hidden'
) {
this.showStatus(message);
show();
return;
}
let fallbackTimer = null;
@@ -555,7 +619,7 @@ export class ShareRestoration {
this._disposed,
)
)
this.showStatus(message);
show();
};
removeStartupListener = this._lifetime.listen(
startupCover,
+18
View File
@@ -166,6 +166,24 @@ html:not([lang='en']) #global-loading-detail {
#global-loading-status[data-state='cancelled'] #global-loading-detail {
color: #ffd488;
}
/* A last-known notice is a sentence with the entity's id on a second line;
the id is shown whole (a vessel UUID is 36 characters). */
#global-loading-status[data-state='last-known'] {
flex-direction: column;
gap: 3px;
border-color: rgba(255, 212, 136, 0.55);
text-align: center;
}
#global-loading-status[data-state='last-known'] #global-loading-label {
color: #ffd488;
}
#global-loading-status[data-state='last-known'] #global-loading-detail {
max-width: none;
color: rgba(255, 228, 176, 0.82);
letter-spacing: 1.2px;
text-align: center;
text-transform: none;
}
#global-loading-status[data-state='acquiring'] {
border-color: rgba(0, 212, 255, 0.42);
box-shadow:
+11 -4
View File
@@ -2824,7 +2824,7 @@ async function trackEntity(
() => {
let trackedOk = false;
if (family.kind === 'vessel') {
trackedOk = !!module.selectById?.(found.mmsi);
trackedOk = !!module.selectById?.(found.id ?? found.mmsi);
flyToLandmark(viewer, found.latitude, found.longitude, {
range: 6000,
pitch: -45,
@@ -4003,8 +4003,13 @@ function locateLayerEntity(dataManager, { layerId, id, lat, lon }) {
?.module?.getAnalystRecords?.()
.filter(
(item) =>
String(item.icao24 ?? item.mmsi ?? item.noradId ?? item.id) ===
String(id),
String(
item.icao24 ??
item.vesselId ??
item.mmsi ??
item.noradId ??
item.id,
) === String(id),
) || [];
// Bikeshare uses station names as IDs: retain the coordinate identity
// even if a namesake disappears. Moving records use their fresh fix.
@@ -5344,6 +5349,7 @@ function cameraViewBox(viewer) {
/** Identity fields every compact analyst item keeps when present. */
const ANALYST_IDENTITY_FIELDS = Object.freeze([
'icao24',
'vesselId',
'mmsi',
'registration',
'label',
@@ -5361,6 +5367,7 @@ const ANALYST_CANONICAL_FIELDS = new Set([
'layerKey',
'id',
'icao24',
'vesselId',
'mmsi',
'noradId',
'registration',
@@ -5373,7 +5380,7 @@ const ANALYST_CANONICAL_FIELDS = new Set([
* follow-up can fly to it), distance, and that layer's manifest fields with
* times as ISO text. The full record set stays engine-side for follow-ups.
*
* `icao24`/`mmsi` ride along because the tool instructions tell the model to
* `icao24`/`vesselId`/`mmsi` ride along because the tool instructions tell the model to
* hand this result straight to track_entity, and `id` is a DISPLAY label
* (callsign, else registration, else hex). A callsign-less contact therefore
* handed track_entity a tail number the lookup could not resolve, and the
+25
View File
@@ -245,6 +245,31 @@ test('P2-7: duplicate callsigns and names resolve by canonical identity', () =>
);
});
test('vessels sharing an MMSI resolve by their vessel id', () => {
const referents = createReferentRegistry();
referents.recordResult('analyst_query', {
ok: true,
items: ['hull-a', 'hull-b'].map((vesselId) => ({
layerKey: 'ais-live-vessels',
id: 'EVER GIVEN',
vesselId,
mmsi: '353136000',
name: 'EVER GIVEN',
})),
});
assert.deepEqual(
[1, 2].map(
(n) =>
resolveDeicticArgs(
'track_entity',
{ query: 'x', referent: n },
{ referents },
).args.query,
),
['hull-a', 'hull-b'],
);
});
test('P1-1: a result from before the last clear cannot repopulate the registry', () => {
const referents = createReferentRegistry();
const since = referents.generation();
+10 -3
View File
@@ -23,10 +23,17 @@ function finite(value) {
function normalize(entry, index) {
if (!entry || typeof entry !== 'object') return null;
// Identity is the canonical key (transponder hex, MMSI, NORAD id); a
// Identity is the canonical key (transponder hex, vessel id, NORAD id); a
// display `id` such as a callsign is only a label, and two contacts can
// share one.
const id = entry.icao24 ?? entry.mmsi ?? entry.noradId ?? entry.id ?? null;
// share one. Vessels can share an MMSI too, so a vessel's key is its
// `vesselId` when the row names one.
const id =
entry.icao24 ??
entry.vesselId ??
entry.mmsi ??
entry.noradId ??
entry.id ??
null;
const label = String(
entry.label ?? entry.callsign ?? entry.name ?? entry.id ?? id ?? '',
).trim();
+1 -1
View File
@@ -501,7 +501,7 @@ function frameOverhead(result) {
.filter((entry) => entry?.label || entry?.id)
.map((entry, index) => ({
n: index + 1,
id: entry.icao24 || entry.mmsi || entry.id || null,
id: entry.icao24 || entry.id || entry.mmsi || null,
label: spokenLabel(entry.label || entry.id),
layerId: result.layerId,
}));