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

+30 -1
View File
@@ -3,5 +3,34 @@
(`source.timeline`, read by `readSnapshotTimeline` from
`gods-eye-view/sources/live`). Away from live they show the snapshot at the
clock's time and move on with it as it plays; back at live they poll as
before. A source with no timeline keeps them live, unchanged (Sameh Khamis,
before. A source's `timeline.subscribe` may also tell of a better answer
for a time it has answered; paused, the layer loads the time shown again.
A source with no timeline keeps them live, unchanged (Sameh Khamis,
@samehkhamis, #PR).
- AIS Vessels are keyed by the stable id their live source gives (`id` on a
vessel record), not by MMSI: vessels can share an MMSI, through spoofing,
reused numbers or defaults such as 0, and a number can move to another
hull. Two vessels sharing an MMSI are now two vessels, each selected,
followed and carded on its own. The MMSI is an attribute (`mmsi`), shown
when known and `--` otherwise, up to ten digits; looking a vessel up or
selecting it by MMSI (an older link, say) still finds a vessel with it, and
a vessel with no name is no longer named by its id. `vesselSnapshot` takes
`idFor(row)` for a source whose rows carry an id of their own. Tool and voice results carry the vessel's id beside its MMSI
(`id`, or `vesselId` where `id` is a display label), and `get_vessel_track`
also takes an `id`. AISStream has only MMSIs, so its vessels are keyed by
MMSI and nothing changes there (Sameh Khamis, @samehkhamis, #PR).
- A share link made while a vessel is selected (and the camera follows
nothing) now opens that vessel: the link names it, opens with the camera
where it was, looking at the vessel, and selects the vessel once the feed
has it, at the link's world time when it has one. A vessel id that is a
source's stable id, such as a UUID, travels as its 16 bytes in a new thing
field (13), so the link stays short (62 characters where a followed
aircraft's is 44). MMSI and other ids, and links already shared, are
unchanged; an app that predates the field opens such a link at the same
camera, without selecting the vessel. A camera that no follow offset can
carry (below the vessel's horizon, say) still makes a place view.
A shared flight, military flight, vessel or satellite that is not reporting
now says so in a sentence ("The shared vessel isn't reporting now — showing
where it was at 14:05"), with its id on a second line under it (ICAO,
NORAD, MMSI or vessel UUID) instead of at the head of the sentence (Sameh
Khamis, @samehkhamis, #PR).
+3 -2
View File
@@ -634,8 +634,9 @@ source-time fallback and model-owned ground policy. The renderer supplies that
ownership fact and handles stale-ground lifting, Cesium history and primitives;
record reconciliation does not import the engine or application.
`layers/vessels/records` owns plain AIS metadata, stable MMSI identity and bounded
retention. `layers/vessels/ingestion` owns source requests and feed state through
`layers/vessels/records` owns plain AIS metadata, identity by the stable id the
source gives (an MMSI only for a feed that has nothing else; the MMSI itself is
an attribute) and bounded retention. `layers/vessels/ingestion` owns source requests and feed state through
explicit operations. Both exports exclude Cesium, DOM and application assembly.
The snapshot renderer applies record changes; rendering owns a weak map of
geometry and billboard resources used by cards, picking and trails.
+41 -14
View File
@@ -28,7 +28,11 @@ Following an aircraft is 42–50: 42 when the link is made from the app's own
follow camera with the fix taken as the link is made, 44 with a fix four
seconds older, and 50 after orbiting to another heading and pitch. A view in
any other style that keeps the style's own look adds only the style (two
bytes): the 42-character followed aircraft in Noir is 44.
bytes): the 42-character followed aircraft in Noir is 44. A selected vessel
named by its MMSI costs what an aircraft does (46 characters for the
44-character aircraft above); one named by a UUID costs 13 bytes more, as its
16 bytes travel raw (62 characters where the aircraft is 44, rather than about
88 with the UUID as text).
## Opening a short link
@@ -47,9 +51,22 @@ best effort: until the entity is found, and if it is not found in time, the
camera is where the link was made, looking at the entity's last-known fix,
and the app says that the entity isn't reporting and when the fix was taken.
The share button makes a thing view while the camera follows an aircraft,
military aircraft or satellite. A selected vessel is not followed (the camera
stays where the user puts it), so a link made then is a place view; vessel
thing views open, but the app does not make them yet.
military aircraft or satellite. While the camera follows nothing and a vessel
is selected, it makes a vessel thing view: the fix is the vessel's position
as drawn (as of the world time while the vessel layer follows the world
clock, else as of its latest position report), and the offset is the one whose derived camera is
where the user's camera is (`followOffsetFor` in `src/share/follow.js`, the
inverse of `followCameraFor`, taken from the fix at the format's precision).
The opened camera is then within the offset's whole metres and degrees of
where the link was made, looking at the vessel. When no offset can carry the
camera, because it is not above the vessel's local horizontal (it would look
up at the vessel, or through the earth) or its range is outside 1 m to
10⁸ m, the link is a place view as before. A vessel is selected, not
followed, so the camera stays where the user puts it. Following an entity
takes priority over a selected vessel, and a followed entity the format
cannot name still makes a place view. Opening a vessel thing view with a
world time waits for the vessel layer's snapshot at that time and selects the
vessel once it has it.
## Payload
@@ -274,14 +291,23 @@ sub-message:
| 10 | fix alt | sint metres | 0 |
| 11 | fix age | sint seconds the fix predates the created-at time | 0 |
| 12 | observed-at | epoch, when the fix was taken; only without a created-at | none |
| 13 | vessel id | UUID, 16 bytes (LEN) | |
The entity kind is the field that carries the id (its code in
`SHARE_VIEW_ENTITY_KINDS`), so it costs nothing; exactly one of fields 1–4 is
present (two is malformed). Entity ids are the ones the layers look up: an
aircraft's ICAO 24-bit address (six hex digits, stored as a number; other
forms such as TIS-B `~abc123` as text), a vessel's MMSI and a satellite's
NORAD catalog number (decimal numbers; an MMSI with a leading zero stays
text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`.
`SHARE_VIEW_ENTITY_KINDS`, or field 13 for a vessel), so it costs nothing;
exactly one of fields 1–4 and 13 is present (two is malformed). Entity ids are
the ones the layers look up: an aircraft's ICAO 24-bit address (six hex
digits, stored as a number; other forms such as TIS-B `~abc123` as text), a
vessel's id (its MMSI, or a source's stable id, such as a UUID) and a
satellite's NORAD catalog number (decimal numbers; an MMSI with a leading
zero stays text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`, except a
vessel id that is a UUID (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, lowercase
hex): it is written as its 16 bytes in field 13 rather than as 36 characters
of text in field 3, after the other thing fields, and decodes as the
lowercase dashed UUID. Field 13 of any other length is malformed. A decoder
that predates field 13 skips it, finds no entity and opens the view as a
place at its derived camera (`unknown-entity-kind`), as for a new entity kind,
so the field needed no new version.
Fields 5–7 are the follow offset: the camera looks along (heading, pitch) at
the entity from `range` metres away, heading clockwise from north, in the
@@ -319,8 +345,9 @@ bytes, a check byte that does not match (`checksum`), an unknown version byte,
a truncated value, a varint over 8 bytes or above 2⁵³ − 1, an
overlong varint, a LEN longer than what remains (at any level, including
inside the thing sub-message), a reserved wire type, field number 0, a known
field with the wrong wire type or repeated, two entity ids in one thing, a
duplicate panel/option/style entry, non-ASCII text, and a latitude or
field with the wrong wire type or repeated, two entity ids in one thing
(field 13 counts as one), a UUID vessel id that is not 16 bytes
(`bad-length`), a duplicate panel/option/style entry, non-ASCII text, and a latitude or
longitude (camera or fix) beyond ±90/±180.
Tolerated and reported in `unknownFields`: unknown fields at any level
@@ -358,8 +385,8 @@ the app that can open it.
boolean takes the next flag bit instead, which costs nothing when it is at
its default.
6. **New view kinds and entity kinds** get the next code. A new entity kind's
id takes the next free thing field (13 onwards), so older apps skip it and
fall back to the camera. A thing that needs more than an id (for example a
id takes the next free thing field (14 onwards; 13 is a vessel id that is
a UUID), so older apps skip it and fall back to the camera. A thing that needs more than an id (for example a
CCTV camera, a radio station or a transit vehicle) adds further thing
fields the same way.
7. **A new layer option** needs an entry in `SHARE_VIEW_LAYER_OPTION_ABSENT`
+1 -1
View File
@@ -270,7 +270,7 @@ and the development server's internal routes. See SECURITY.md.
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match |
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
| `get_vessel_track` | `vessels` | Recent positions of one ship by vessel id or MMSI, thinned to 200 points |
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a place or point now, highest first |
| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first |
+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,
}));