mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 05:42:22 +00:00
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:
1 parent
57ff81f723
commit
cbbf55cacd
74 files changed
+2481
-586
No files matched your search
@@ -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).
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }],
|
||||
];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+698
-206
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -255,7 +255,7 @@ export class CockpitCoordinator {
|
||||
: vessel
|
||||
? {
|
||||
kind: 'vessel',
|
||||
id: vessel.mmsi,
|
||||
id: vessel.id,
|
||||
layerId: vessel.layerId,
|
||||
callsign: null,
|
||||
name: vessel.name,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}));
|
||||
|
||||
Reference in new issue
Block a user