mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +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
|
(`source.timeline`, read by `readSnapshotTimeline` from
|
||||||
`gods-eye-view/sources/live`). Away from live they show the snapshot at the
|
`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
|
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).
|
@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;
|
ownership fact and handles stale-ground lifting, Cesium history and primitives;
|
||||||
record reconciliation does not import the engine or application.
|
record reconciliation does not import the engine or application.
|
||||||
|
|
||||||
`layers/vessels/records` owns plain AIS metadata, stable MMSI identity and bounded
|
`layers/vessels/records` owns plain AIS metadata, identity by the stable id the
|
||||||
retention. `layers/vessels/ingestion` owns source requests and feed state through
|
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.
|
explicit operations. Both exports exclude Cesium, DOM and application assembly.
|
||||||
The snapshot renderer applies record changes; rendering owns a weak map of
|
The snapshot renderer applies record changes; rendering owns a weak map of
|
||||||
geometry and billboard resources used by cards, picking and trails.
|
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
|
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
|
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
|
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
|
## 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,
|
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.
|
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,
|
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
|
military aircraft or satellite. While the camera follows nothing and a vessel
|
||||||
stays where the user puts it), so a link made then is a place view; vessel
|
is selected, it makes a vessel thing view: the fix is the vessel's position
|
||||||
thing views open, but the app does not make them yet.
|
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
|
## Payload
|
||||||
|
|
||||||
@@ -274,14 +291,23 @@ sub-message:
|
|||||||
| 10 | fix alt | sint metres | 0 |
|
| 10 | fix alt | sint metres | 0 |
|
||||||
| 11 | fix age | sint seconds the fix predates the created-at time | 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 |
|
| 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
|
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
|
`SHARE_VIEW_ENTITY_KINDS`, or field 13 for a vessel), so it costs nothing;
|
||||||
present (two is malformed). Entity ids are the ones the layers look up: an
|
exactly one of fields 1–4 and 13 is present (two is malformed). Entity ids are
|
||||||
aircraft's ICAO 24-bit address (six hex digits, stored as a number; other
|
the ones the layers look up: an aircraft's ICAO 24-bit address (six hex
|
||||||
forms such as TIS-B `~abc123` as text), a vessel's MMSI and a satellite's
|
digits, stored as a number; other forms such as TIS-B `~abc123` as text), a
|
||||||
NORAD catalog number (decimal numbers; an MMSI with a leading zero stays
|
vessel's id (its MMSI, or a source's stable id, such as a UUID) and a
|
||||||
text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`.
|
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
|
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
|
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
|
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
|
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
|
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
|
field with the wrong wire type or repeated, two entity ids in one thing
|
||||||
duplicate panel/option/style entry, non-ASCII text, and a latitude or
|
(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.
|
longitude (camera or fix) beyond ±90/±180.
|
||||||
|
|
||||||
Tolerated and reported in `unknownFields`: unknown fields at any level
|
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
|
boolean takes the next flag bit instead, which costs nothing when it is at
|
||||||
its default.
|
its default.
|
||||||
6. **New view kinds and entity kinds** get the next code. A new entity kind's
|
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
|
id takes the next free thing field (14 onwards; 13 is a vessel id that is
|
||||||
fall back to the camera. A thing that needs more than an id (for example a
|
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
|
CCTV camera, a radio station or a transit vehicle) adds further thing
|
||||||
fields the same way.
|
fields the same way.
|
||||||
7. **A new layer option** needs an entry in `SHARE_VIEW_LAYER_OPTION_ABSENT`
|
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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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';
|
import { mapAnalystRecord } from './aisLiveVessels.js';
|
||||||
|
|
||||||
const FULL_RECORD = {
|
const FULL_RECORD = {
|
||||||
|
id: '353136000',
|
||||||
mmsi: '353136000',
|
mmsi: '353136000',
|
||||||
name: 'EVER GIVEN',
|
name: 'EVER GIVEN',
|
||||||
lat: 29.55,
|
lat: 29.55,
|
||||||
@@ -20,6 +21,7 @@ test('ais analyst record: full record maps every contract field', () => {
|
|||||||
const r = mapAnalystRecord(FULL_RECORD);
|
const r = mapAnalystRecord(FULL_RECORD);
|
||||||
assert.deepEqual(r, {
|
assert.deepEqual(r, {
|
||||||
id: 'EVER GIVEN',
|
id: 'EVER GIVEN',
|
||||||
|
vesselId: '353136000',
|
||||||
mmsi: '353136000',
|
mmsi: '353136000',
|
||||||
name: 'EVER GIVEN',
|
name: 'EVER GIVEN',
|
||||||
lat: 29.55,
|
lat: 29.55,
|
||||||
@@ -38,6 +40,16 @@ test('ais analyst record: nameless vessel falls back to mmsi id', () => {
|
|||||||
assert.equal(r.name, null);
|
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', () => {
|
test('ais analyst record: empty strings and NaN become null, never undefined', () => {
|
||||||
const r = mapAnalystRecord({ mmsi: '', name: 'TUG', speed: NaN, type: '', destination: '' });
|
const r = mapAnalystRecord({ mmsi: '', name: 'TUG', speed: NaN, type: '', destination: '' });
|
||||||
assert.equal(r.mmsi, null);
|
assert.equal(r.mmsi, null);
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ test('zero accepted rows preserve warm vessel selection, trail, and freshness ti
|
|||||||
records: [record],
|
records: [record],
|
||||||
selectedRecord: record,
|
selectedRecord: record,
|
||||||
trail,
|
trail,
|
||||||
trailMmsi: record.mmsi,
|
trailId: record.mmsi,
|
||||||
trailPositions: [POS],
|
trailPositions: [POS],
|
||||||
loaded: true,
|
loaded: true,
|
||||||
lastUpdate: 456,
|
lastUpdate: 456,
|
||||||
@@ -314,8 +314,8 @@ test('zero accepted rows preserve warm vessel selection, trail, and freshness ti
|
|||||||
lastMessageAt: 789,
|
lastMessageAt: 789,
|
||||||
rawRowCount: 1,
|
rawRowCount: 1,
|
||||||
acceptedRowCount: 0,
|
acceptedRowCount: 0,
|
||||||
selectedMmsi: record.mmsi,
|
selectedId: record.mmsi,
|
||||||
trailMmsi: record.mmsi,
|
trailId: record.mmsi,
|
||||||
trailPositionCount: 1,
|
trailPositionCount: 1,
|
||||||
sessionId: _getVesselFeedStateForTest().sessionId,
|
sessionId: _getVesselFeedStateForTest().sessionId,
|
||||||
firstConnectPhase: 'idle',
|
firstConnectPhase: 'idle',
|
||||||
@@ -565,7 +565,7 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
|
|||||||
records: [replacement],
|
records: [replacement],
|
||||||
selectedRecord: replacement,
|
selectedRecord: replacement,
|
||||||
trail: replacementTrail,
|
trail: replacementTrail,
|
||||||
trailMmsi: replacement.mmsi,
|
trailId: replacement.mmsi,
|
||||||
trailPositions: [POS],
|
trailPositions: [POS],
|
||||||
loaded: true,
|
loaded: true,
|
||||||
lastUpdate: 200,
|
lastUpdate: 200,
|
||||||
@@ -581,7 +581,7 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
|
|||||||
let feed = _getVesselFeedStateForTest();
|
let feed = _getVesselFeedStateForTest();
|
||||||
assert.equal(feed.loading, true, 'old finally must not clear replacement loading ownership');
|
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.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: [] }));
|
second.resolve(jsonResponse({ status: 'open', lastMessageAt: null, rows: [] }));
|
||||||
await replacementLoad;
|
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.lastUpdate, 200);
|
||||||
assert.equal(feed.error, 'awaiting first AIS message…');
|
assert.equal(feed.error, 'awaiting first AIS message…');
|
||||||
assert.equal(feed.stale, true);
|
assert.equal(feed.stale, true);
|
||||||
assert.equal(feed.selectedMmsi, replacement.mmsi);
|
assert.equal(feed.selectedId, replacement.mmsi);
|
||||||
assert.equal(feed.trailMmsi, replacement.mmsi);
|
assert.equal(feed.trailId, replacement.mmsi);
|
||||||
assert.equal(replacementTrail.clearCalls, 0);
|
assert.equal(replacementTrail.clearCalls, 0);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = priorFetch;
|
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', () => {
|
test('vessel selection: empty-space click requests deselection', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
reduceVesselSelection({ selectedMmsi: '353136000', gesture: 'click' }),
|
reduceVesselSelection({ selectedId: '353136000', gesture: 'click' }),
|
||||||
{ action: 'deselect' },
|
{ action: 'deselect' },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('vessel selection: no selection plus empty-space click is a no-op', () => {
|
test('vessel selection: no selection plus empty-space click is a no-op', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
reduceVesselSelection({ selectedMmsi: null, pickedMmsi: null, gesture: 'click' }),
|
reduceVesselSelection({ selectedId: null, pickedId: null, gesture: 'click' }),
|
||||||
{ action: 'none' },
|
{ action: 'none' },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('vessel selection: Escape deselects only when this layer owns a selection', () => {
|
test('vessel selection: Escape deselects only when this layer owns a selection', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
reduceVesselSelection({ selectedMmsi: 353136000, gesture: 'escape' }),
|
reduceVesselSelection({ selectedId: 353136000, gesture: 'escape' }),
|
||||||
{ action: 'deselect' },
|
{ action: 'deselect' },
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
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', () => {
|
test('vessel selection: another vessel replaces selection; same vessel is a no-op', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
reduceVesselSelection({
|
reduceVesselSelection({
|
||||||
selectedMmsi: '353136000',
|
selectedId: '353136000',
|
||||||
pickedMmsi: '367123450',
|
pickedId: '367123450',
|
||||||
gesture: 'click',
|
gesture: 'click',
|
||||||
}),
|
}),
|
||||||
{ action: 'select' },
|
{ action: 'select' },
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
reduceVesselSelection({
|
reduceVesselSelection({
|
||||||
selectedMmsi: '353136000',
|
selectedId: '353136000',
|
||||||
pickedMmsi: '353136000',
|
pickedId: '353136000',
|
||||||
gesture: 'click',
|
gesture: 'click',
|
||||||
}),
|
}),
|
||||||
{ action: 'none' },
|
{ 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);
|
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 = {}) {
|
function makeRecord(overrides = {}) {
|
||||||
return {
|
const record = {
|
||||||
name: 'EVER GIVEN',
|
name: 'EVER GIVEN',
|
||||||
mmsi: '353136000',
|
mmsi: '353136000',
|
||||||
type: 'Container Ship',
|
type: 'Container Ship',
|
||||||
@@ -849,6 +850,8 @@ function makeRecord(overrides = {}) {
|
|||||||
billboard: null,
|
billboard: null,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
if (!Object.hasOwn(overrides, 'id')) record.id = record.mmsi;
|
||||||
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeTrailSpy() {
|
function makeTrailSpy() {
|
||||||
@@ -948,7 +951,7 @@ function installWireHarness(picked, stateOverrides = {}) {
|
|||||||
records: [record],
|
records: [record],
|
||||||
selectedRecord: record,
|
selectedRecord: record,
|
||||||
trail,
|
trail,
|
||||||
trailMmsi: record.mmsi,
|
trailId: record.mmsi,
|
||||||
trailPositions: [POS],
|
trailPositions: [POS],
|
||||||
interactionHandlerFactory,
|
interactionHandlerFactory,
|
||||||
interactionKeyTarget: keyTarget,
|
interactionKeyTarget: keyTarget,
|
||||||
@@ -983,7 +986,7 @@ test('vessel interaction wire: trail pick does not deselect', () => {
|
|||||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||||||
assert.equal(harness.trail.clearCalls, 0);
|
assert.equal(harness.trail.clearCalls, 0);
|
||||||
assert.equal(_getVesselStateForTest().trailMmsi, harness.record.mmsi);
|
assert.equal(_getVesselStateForTest().trailId, harness.record.mmsi);
|
||||||
} finally {
|
} finally {
|
||||||
harness.cleanup();
|
harness.cleanup();
|
||||||
}
|
}
|
||||||
@@ -1213,7 +1216,7 @@ test('vessel interaction lifecycle: disable detaches input and enable reinstalls
|
|||||||
selectedRecord: harness.record,
|
selectedRecord: harness.record,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
trail: harness.trail,
|
trail: harness.trail,
|
||||||
trailMmsi: harness.record.mmsi,
|
trailId: harness.record.mmsi,
|
||||||
trailPositions: [POS],
|
trailPositions: [POS],
|
||||||
interactionHandlerFactory: harness.interactionHandlerFactory,
|
interactionHandlerFactory: harness.interactionHandlerFactory,
|
||||||
interactionKeyTarget: harness.keyTarget,
|
interactionKeyTarget: harness.keyTarget,
|
||||||
@@ -1287,7 +1290,7 @@ test('vessel trail lifecycle: deselect clears the selected-vessel trail', () =>
|
|||||||
aisLiveVesselsLayer.clearSelection();
|
aisLiveVesselsLayer.clearSelection();
|
||||||
assert.equal(harness.trail.clearCalls, 1);
|
assert.equal(harness.trail.clearCalls, 1);
|
||||||
assert.deepEqual(_getVesselStateForTest(), {
|
assert.deepEqual(_getVesselStateForTest(), {
|
||||||
trailMmsi: null,
|
trailId: null,
|
||||||
trailPositionCount: 0,
|
trailPositionCount: 0,
|
||||||
vesselCount: 1,
|
vesselCount: 1,
|
||||||
});
|
});
|
||||||
@@ -1302,14 +1305,14 @@ test('vessel trail lifecycle: reconciliation eviction clears an orphaned trail',
|
|||||||
_setVesselStateForTest({
|
_setVesselStateForTest({
|
||||||
records: [record],
|
records: [record],
|
||||||
trail,
|
trail,
|
||||||
trailMmsi: record.mmsi,
|
trailId: record.mmsi,
|
||||||
trailPositions: [POS],
|
trailPositions: [POS],
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
_reconcileVesselsForTest({}, []);
|
_reconcileVesselsForTest({}, []);
|
||||||
assert.equal(trail.clearCalls, 1);
|
assert.equal(trail.clearCalls, 1);
|
||||||
assert.deepEqual(_getVesselStateForTest(), {
|
assert.deepEqual(_getVesselStateForTest(), {
|
||||||
trailMmsi: null,
|
trailId: null,
|
||||||
trailPositionCount: 0,
|
trailPositionCount: 0,
|
||||||
vesselCount: 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
|
* 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
|
* 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()));
|
const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord()));
|
||||||
assert.equal(keyed.interactive, true);
|
assert.equal(keyed.interactive, true);
|
||||||
const unkeyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord({ mmsi: '' })));
|
const unkeyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord({ mmsi: '' })));
|
||||||
assert.equal(unkeyed.interactive, false, 'an unkeyed card has no record to select');
|
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', () => {
|
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: [] }),
|
getSnapshot: async () => ({ records: [] }),
|
||||||
getTrack: async (reference, options) => { requests.push({ reference, options }); return { 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 {
|
try {
|
||||||
harness.record.reference = 'opaque:test-reference';
|
harness.record.reference = 'opaque:test-reference';
|
||||||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
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 = [
|
const SIBLING_PICKS = [
|
||||||
['flights', 'a1b2c3', { id: 'a1b2c3' }],
|
['flights', 'a1b2c3', { id: 'a1b2c3' }],
|
||||||
['flights', 'a1b2c3', { primitive: { 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' } }],
|
['cctv', 'atx-cam-3', { id: { id: 'atx-cam-3', name: 'entity-like' } }],
|
||||||
['satellites', '25544', { id: 25544 }],
|
['satellites', '25544', { id: 25544 }],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1807,7 +1807,7 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
|
|||||||
[
|
[
|
||||||
'aisLiveVessels',
|
'aisLiveVessels',
|
||||||
readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)),
|
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(
|
assert.match(
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ export function formatAwarenessLabel(value) {
|
|||||||
value.callsign,
|
value.callsign,
|
||||||
value.name,
|
value.name,
|
||||||
value.label,
|
value.label,
|
||||||
|
value.mmsi,
|
||||||
value.id,
|
value.id,
|
||||||
value.icao24,
|
value.icao24,
|
||||||
value.mmsi,
|
|
||||||
]) {
|
]) {
|
||||||
const label = formatAwarenessLabel(candidate);
|
const label = formatAwarenessLabel(candidate);
|
||||||
if (label !== '—') return label;
|
if (label !== '—') return label;
|
||||||
@@ -154,7 +154,7 @@ export function getAwarenessNavigationTargets(
|
|||||||
? cohort.summary.nearest
|
? cohort.summary.nearest
|
||||||
: [];
|
: [];
|
||||||
for (const item of sourceItems) {
|
for (const item of sourceItems) {
|
||||||
const id = item?.icao24 || item?.mmsi || item?.id;
|
const id = item?.icao24 || item?.id || item?.mmsi;
|
||||||
if (!id) continue;
|
if (!id) continue;
|
||||||
const key = `${layerId}:${id}`;
|
const key = `${layerId}:${id}`;
|
||||||
if (key === `${subject?.layerId}:${subject?.id}`) continue;
|
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
|
* Layers use heterogeneous pick ids: flights/military/bikeshare/CCTV use
|
||||||
* strings, satellites use numeric NORAD catalog ids, live AIS vessels attach
|
* 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
|
* the Cesium Entity (identity = its string `id`). Everything is coerced to a
|
||||||
* String so predicates match against one canonical form.
|
* String so predicates match against one canonical form.
|
||||||
*
|
*
|
||||||
@@ -33,9 +33,7 @@ export function resolvePickId(picked) {
|
|||||||
const unwrap = (id) => {
|
const unwrap = (id) => {
|
||||||
if (id === null || id === undefined) return undefined;
|
if (id === null || id === undefined) return undefined;
|
||||||
if (typeof id === 'object') {
|
if (typeof id === 'object') {
|
||||||
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string)
|
// AIS vessel record or Cesium Entity: both are keyed by their `.id`.
|
||||||
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number')
|
|
||||||
return id.mmsi;
|
|
||||||
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
|
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ test('resolvePickId: numeric pick id (satellite NORAD numbers) → String', () =
|
|||||||
assert.equal(resolvePickId({ id: undefined, primitive: { id: 43013 } }), '43013');
|
assert.equal(resolvePickId({ id: undefined, primitive: { id: 43013 } }), '43013');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolvePickId: AIS vessel record object → its mmsi', () => {
|
test('resolvePickId: AIS vessel record object → its id, not its MMSI', () => {
|
||||||
const record = { mmsi: '367123450', name: 'EVER GIVEN', billboard: {} };
|
const record = { id: '367123450', mmsi: '367123450', name: 'EVER GIVEN', billboard: {} };
|
||||||
assert.equal(resolvePickId({ id: record }), '367123450');
|
assert.equal(resolvePickId({ id: record }), '367123450');
|
||||||
assert.equal(resolvePickId({ primitive: { 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', () => {
|
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(null), null);
|
||||||
assert.equal(resolvePickId(undefined), null);
|
assert.equal(resolvePickId(undefined), null);
|
||||||
assert.equal(resolvePickId({}), 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);
|
assert.equal(resolvePickId({ primitive: {} }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,8 +69,8 @@ test('ownership: sibling layers recognize each other via String-coerced ids', ()
|
|||||||
try {
|
try {
|
||||||
// Flights asking about a satellite pick (raw numeric id → String upstream)
|
// Flights asking about a satellite pick (raw numeric id → String upstream)
|
||||||
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: 25544 })), true);
|
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: 25544 })), true);
|
||||||
// Flights asking about a vessel pick (record object → mmsi)
|
// Flights asking about a vessel pick (record object → id)
|
||||||
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: { mmsi: '367123450' } })), true);
|
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: { id: '367123450', mmsi: '367123450' } })), true);
|
||||||
// A layer never owns its own pick via the sibling scan
|
// A layer never owns its own pick via the sibling scan
|
||||||
assert.equal(isOwnedByOtherLayer('satellites', '25544'), false);
|
assert.equal(isOwnedByOtherLayer('satellites', '25544'), false);
|
||||||
// Unknown ids belong to nobody
|
// 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) => {
|
test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => {
|
||||||
primaryPointer(t, true);
|
primaryPointer(t, true);
|
||||||
contactOwners(t);
|
contactOwners(t);
|
||||||
for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) {
|
for (const first of [{ id: 'military' }, { id: { id: '123456789', mmsi: '123456789' } }]) {
|
||||||
const scene = {
|
const scene = {
|
||||||
pick: () => mapTile,
|
pick: () => mapTile,
|
||||||
drillPick: () => [mapTile, first, { id: 'civil' }],
|
drillPick: () => [mapTile, first, { id: 'civil' }],
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ function contextCarrierRecord(picked, dataManager) {
|
|||||||
function fromRecord(record) {
|
function fromRecord(record) {
|
||||||
const layerId = record.layerId || null;
|
const layerId = record.layerId || null;
|
||||||
const id =
|
const id =
|
||||||
layerId === 'ais-live-vessels' && record.properties?.mmsi
|
layerId === 'ais-live-vessels' && record.properties?.vesselId
|
||||||
? String(record.properties.mmsi)
|
? String(record.properties.vesselId)
|
||||||
: String(record.id);
|
: String(record.id);
|
||||||
return {
|
return {
|
||||||
layerId,
|
layerId,
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function applyVesselOverlayPolicy(
|
|||||||
edgeFade: 'keyhole',
|
edgeFade: 'keyhole',
|
||||||
horizonCull: true,
|
horizonCull: true,
|
||||||
terrainOcclusion: false,
|
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,
|
interactive: card?.actionable === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2676,8 +2676,6 @@
|
|||||||
"Shared flight is unavailable": "الرحلة المشتركة غير متاحة",
|
"Shared flight is unavailable": "الرحلة المشتركة غير متاحة",
|
||||||
"Shared military flight is unavailable": "الرحلة العسكرية المشتركة غير متاحة",
|
"Shared military flight is unavailable": "الرحلة العسكرية المشتركة غير متاحة",
|
||||||
"Shared satellite 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": "لا يمكن فتح رابط المشاركة هذا",
|
"This share link can't be opened": "لا يمكن فتح رابط المشاركة هذا",
|
||||||
"Shared vessel is unavailable": "السفينة المشتركة غير متاحة",
|
"Shared vessel is unavailable": "السفينة المشتركة غير متاحة",
|
||||||
"subject": "الهدف",
|
"subject": "الهدف",
|
||||||
@@ -3778,5 +3776,17 @@
|
|||||||
"Play speed": "سرعة التشغيل",
|
"Play speed": "سرعة التشغيل",
|
||||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل",
|
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل",
|
||||||
"1 min/s": "دقيقة/ث",
|
"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 flight is unavailable": "Geteilter Flug ist nicht verfügbar",
|
||||||
"Shared military flight is unavailable": "Geteilter Militärflug 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",
|
"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",
|
"This share link can't be opened": "Dieser Freigabelink kann nicht geöffnet werden",
|
||||||
"Shared vessel is unavailable": "Geteiltes Schiff ist nicht verfügbar",
|
"Shared vessel is unavailable": "Geteiltes Schiff ist nicht verfügbar",
|
||||||
"subject": "Objekt",
|
"subject": "Objekt",
|
||||||
@@ -3454,5 +3452,17 @@
|
|||||||
"Play speed": "Wiedergabegeschwindigkeit",
|
"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",
|
"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",
|
"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 copied!": "¡Enlace copiado!",
|
||||||
"Link shared": "Enlace compartido",
|
"Link shared": "Enlace compartido",
|
||||||
"This share link can't be opened": "Este enlace compartido no se puede abrir",
|
"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",
|
"Shared vessel is unavailable": "El buque compartido no está disponible",
|
||||||
"Copy failed": "No se pudo copiar",
|
"Copy failed": "No se pudo copiar",
|
||||||
"Location not found": "Ubicación no encontrada",
|
"Location not found": "Ubicación no encontrada",
|
||||||
@@ -3535,5 +3533,17 @@
|
|||||||
"Play speed": "Velocidad de reproducción",
|
"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",
|
"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",
|
"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} follow expired": "Suivi partagé expiré : {subject}",
|
||||||
"Shared {subject} could not be restored — feed unavailable": "Suivi partagé non restauré : {subject} — flux indisponible",
|
"Shared {subject} could not be restored — feed unavailable": "Suivi partagé non restauré : {subject} — flux indisponible",
|
||||||
"Shared {subject} is unavailable": "Partage indisponible : {subject}",
|
"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",
|
"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",
|
"Shared vessel is unavailable": "Le navire partagé est indisponible",
|
||||||
"ACQUIRING": "ACQUISITION",
|
"ACQUIRING": "ACQUISITION",
|
||||||
@@ -3535,5 +3533,17 @@
|
|||||||
"Play speed": "Vitesse de lecture",
|
"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",
|
"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",
|
"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} follow expired": "עקיבת {subject} המשותף פגה",
|
||||||
"Shared {subject} could not be restored — feed unavailable": "לא ניתן לשחזר את {subject} המשותף — הפיד אינו זמין",
|
"Shared {subject} could not be restored — feed unavailable": "לא ניתן לשחזר את {subject} המשותף — הפיד אינו זמין",
|
||||||
"Shared {subject} is 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": "לא ניתן לפתוח את קישור השיתוף הזה",
|
"This share link can't be opened": "לא ניתן לפתוח את קישור השיתוף הזה",
|
||||||
"Shared vessel is unavailable": "האונייה המשותפת אינה זמינה",
|
"Shared vessel is unavailable": "האונייה המשותפת אינה זמינה",
|
||||||
"ACQUIRING": "מאתר",
|
"ACQUIRING": "מאתר",
|
||||||
@@ -3535,5 +3533,17 @@
|
|||||||
"other": "{count} ספינות טרופות"
|
"other": "{count} ספינות טרופות"
|
||||||
},
|
},
|
||||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (מימי אירופה) · ויקינתונים (כל העולם)",
|
"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} 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} 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",
|
"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",
|
"This share link can't be opened": "Impossibile aprire questo link di condivisione",
|
||||||
"Shared vessel is unavailable": "La nave condivisa non è disponibile",
|
"Shared vessel is unavailable": "La nave condivisa non è disponibile",
|
||||||
"ACQUIRING": "ACQUISIZIONE",
|
"ACQUIRING": "ACQUISIZIONE",
|
||||||
@@ -3535,5 +3533,17 @@
|
|||||||
"Play speed": "Velocità di riproduzione",
|
"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",
|
"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",
|
"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} follow expired": "共有された{subject}の追従が期限切れになりました",
|
||||||
"Shared {subject} could not be restored — feed unavailable": "共有された{subject}を復元できませんでした:フィードを利用できません",
|
"Shared {subject} could not be restored — feed unavailable": "共有された{subject}を復元できませんでした:フィードを利用できません",
|
||||||
"Shared {subject} is 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": "この共有リンクは開けません",
|
"This share link can't be opened": "この共有リンクは開けません",
|
||||||
"Shared vessel is unavailable": "共有された船舶は利用できません",
|
"Shared vessel is unavailable": "共有された船舶は利用できません",
|
||||||
"ACQUIRING": "取得中",
|
"ACQUIRING": "取得中",
|
||||||
@@ -3373,5 +3371,17 @@
|
|||||||
"Play speed": "再生速度",
|
"Play speed": "再生速度",
|
||||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示",
|
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示",
|
||||||
"1 min/s": "1分/秒",
|
"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} 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} 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",
|
"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",
|
"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",
|
"Shared vessel is unavailable": "A embarcação compartilhada está indisponível",
|
||||||
"ACQUIRING": "ADQUIRINDO",
|
"ACQUIRING": "ADQUIRINDO",
|
||||||
@@ -3535,5 +3533,17 @@
|
|||||||
"Play speed": "Velocidade de reprodução",
|
"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",
|
"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",
|
"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 copied!": "Ссылка скопирована!",
|
||||||
"Link shared": "Ссылка отправлена",
|
"Link shared": "Ссылка отправлена",
|
||||||
"This share link can't be opened": "Не удаётся открыть эту ссылку",
|
"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": "Судно по ссылке недоступно",
|
"Shared vessel is unavailable": "Судно по ссылке недоступно",
|
||||||
"Copy failed": "Не удалось скопировать",
|
"Copy failed": "Не удалось скопировать",
|
||||||
"VISUAL PRESETS": "ПРЕСЕТЫ",
|
"VISUAL PRESETS": "ПРЕСЕТЫ",
|
||||||
@@ -3616,5 +3614,17 @@
|
|||||||
"Play speed": "Скорость воспроизведения",
|
"Play speed": "Скорость воспроизведения",
|
||||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё",
|
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё",
|
||||||
"1 min/s": "1 мин/с",
|
"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} follow expired": "Посилання на стеження застаріло: {subject}",
|
||||||
"Shared {subject} could not be restored — feed unavailable": "Не вдалося відновити за посиланням: {subject} — потік даних недоступний",
|
"Shared {subject} could not be restored — feed unavailable": "Не вдалося відновити за посиланням: {subject} — потік даних недоступний",
|
||||||
"Shared {subject} is 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": "Не вдається відкрити це посилання",
|
"This share link can't be opened": "Не вдається відкрити це посилання",
|
||||||
"Shared vessel is unavailable": "Судно за посиланням недоступне",
|
"Shared vessel is unavailable": "Судно за посиланням недоступне",
|
||||||
"ACQUIRING": "ОТРИМАННЯ",
|
"ACQUIRING": "ОТРИМАННЯ",
|
||||||
@@ -3616,5 +3614,17 @@
|
|||||||
"Play speed": "Швидкість відтворення",
|
"Play speed": "Швидкість відтворення",
|
||||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все",
|
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все",
|
||||||
"1 min/s": "1 хв/с",
|
"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 copied!": "链接已复制!",
|
||||||
"Link shared": "链接已分享",
|
"Link shared": "链接已分享",
|
||||||
"This share link can't be opened": "无法打开此分享链接",
|
"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": "分享的船只不可用",
|
"Shared vessel is unavailable": "分享的船只不可用",
|
||||||
"Copy failed": "复制失败",
|
"Copy failed": "复制失败",
|
||||||
"Return map to straight-down view": "将地图恢复为垂直俯视",
|
"Return map to straight-down view": "将地图恢复为垂直俯视",
|
||||||
@@ -3373,5 +3371,17 @@
|
|||||||
"Play speed": "播放速度",
|
"Play speed": "播放速度",
|
||||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部",
|
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部",
|
||||||
"1 min/s": "1 分钟/秒",
|
"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}駛出",
|
"Take the exit {direction}": "從出口{direction}駛出",
|
||||||
"Link shared": "鏈接已分享",
|
"Link shared": "鏈接已分享",
|
||||||
"This share link can't be opened": "無法打開此分享鏈接",
|
"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": "分享的船隻不可用",
|
"Shared vessel is unavailable": "分享的船隻不可用",
|
||||||
"Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。",
|
"Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。",
|
||||||
"Press Enter to try again.": "按 Enter 重試。",
|
"Press Enter to try again.": "按 Enter 重試。",
|
||||||
@@ -3373,5 +3371,17 @@
|
|||||||
"Play speed": "播放速度",
|
"Play speed": "播放速度",
|
||||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部",
|
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部",
|
||||||
"1 min/s": "1 分鐘/秒",
|
"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} {angle}°",
|
||||||
"{direction} · {elevation}",
|
"{direction} · {elevation}",
|
||||||
"{source} HTTP {status}",
|
"{source} HTTP {status}",
|
||||||
"▲ {frp} MW"
|
"▲ {frp} MW",
|
||||||
|
"ICAO: {id}",
|
||||||
|
"MMSI: {id}",
|
||||||
|
"NORAD: {id}"
|
||||||
],
|
],
|
||||||
"de": [
|
"de": [
|
||||||
"1090 MHz",
|
"1090 MHz",
|
||||||
@@ -238,7 +241,10 @@
|
|||||||
"ΔN",
|
"ΔN",
|
||||||
"Ⅱ PAUSE",
|
"Ⅱ PAUSE",
|
||||||
"≥ {value} MW",
|
"≥ {value} MW",
|
||||||
"▲ {frp} MW"
|
"▲ {frp} MW",
|
||||||
|
"ICAO: {id}",
|
||||||
|
"MMSI: {id}",
|
||||||
|
"NORAD: {id}"
|
||||||
],
|
],
|
||||||
"es": [
|
"es": [
|
||||||
"1 min/s",
|
"1 min/s",
|
||||||
@@ -395,7 +401,10 @@
|
|||||||
"Reptiles",
|
"Reptiles",
|
||||||
"acartia.io ↗",
|
"acartia.io ↗",
|
||||||
"iNaturalist ↗",
|
"iNaturalist ↗",
|
||||||
"iNaturalist"
|
"iNaturalist",
|
||||||
|
"ICAO: {id}",
|
||||||
|
"MMSI: {id}",
|
||||||
|
"NORAD: {id}"
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"1 min/s",
|
"1 min/s",
|
||||||
@@ -810,7 +819,10 @@
|
|||||||
"acartia.io ↗",
|
"acartia.io ↗",
|
||||||
"iNaturalist ↗",
|
"iNaturalist ↗",
|
||||||
"iNaturalist",
|
"iNaturalist",
|
||||||
"SATELLITE"
|
"SATELLITE",
|
||||||
|
"ICAO: {id}",
|
||||||
|
"MMSI: {id}",
|
||||||
|
"NORAD: {id}"
|
||||||
],
|
],
|
||||||
"ja": [
|
"ja": [
|
||||||
"1090 MHz",
|
"1090 MHz",
|
||||||
@@ -873,7 +885,10 @@
|
|||||||
"ΔE",
|
"ΔE",
|
||||||
"ΔN",
|
"ΔN",
|
||||||
"≥ {value} MW",
|
"≥ {value} MW",
|
||||||
"▲ {frp} MW"
|
"▲ {frp} MW",
|
||||||
|
"ICAO: {id}",
|
||||||
|
"MMSI: {id}",
|
||||||
|
"NORAD: {id}"
|
||||||
],
|
],
|
||||||
"pt": [
|
"pt": [
|
||||||
"1 min/s",
|
"1 min/s",
|
||||||
@@ -1031,7 +1046,10 @@
|
|||||||
"CC BY 4.0",
|
"CC BY 4.0",
|
||||||
"acartia.io ↗",
|
"acartia.io ↗",
|
||||||
"iNaturalist ↗",
|
"iNaturalist ↗",
|
||||||
"iNaturalist"
|
"iNaturalist",
|
||||||
|
"ICAO: {id}",
|
||||||
|
"MMSI: {id}",
|
||||||
|
"NORAD: {id}"
|
||||||
],
|
],
|
||||||
"ru": [
|
"ru": [
|
||||||
"1900–1945",
|
"1900–1945",
|
||||||
@@ -1064,7 +1082,10 @@
|
|||||||
"iNaturalist ↗",
|
"iNaturalist ↗",
|
||||||
"{direction} · {elevation}",
|
"{direction} · {elevation}",
|
||||||
"{direction} {angle}°",
|
"{direction} {angle}°",
|
||||||
"{division}, {country}"
|
"{division}, {country}",
|
||||||
|
"ICAO: {id}",
|
||||||
|
"MMSI: {id}",
|
||||||
|
"NORAD: {id}"
|
||||||
],
|
],
|
||||||
"uk": [
|
"uk": [
|
||||||
"1900–1945",
|
"1900–1945",
|
||||||
@@ -1092,7 +1113,10 @@
|
|||||||
"iNaturalist ↗",
|
"iNaturalist ↗",
|
||||||
"{direction} · {elevation}",
|
"{direction} · {elevation}",
|
||||||
"{direction} {angle}°",
|
"{direction} {angle}°",
|
||||||
"{division}, {country}"
|
"{division}, {country}",
|
||||||
|
"ICAO: {id}",
|
||||||
|
"MMSI: {id}",
|
||||||
|
"NORAD: {id}"
|
||||||
],
|
],
|
||||||
"zh-CN": [
|
"zh-CN": [
|
||||||
"1090 MHz",
|
"1090 MHz",
|
||||||
@@ -1331,6 +1355,9 @@
|
|||||||
"TLE",
|
"TLE",
|
||||||
"{value} KM",
|
"{value} KM",
|
||||||
"{value} KM/S",
|
"{value} KM/S",
|
||||||
"Ironbow"
|
"Ironbow",
|
||||||
|
"ICAO: {id}",
|
||||||
|
"MMSI: {id}",
|
||||||
|
"NORAD: {id}"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -150,7 +150,7 @@ export function createHistory({ state: layerState, services, parts, source }) {
|
|||||||
return (
|
return (
|
||||||
items.find(
|
items.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
String(item?.icao24 || item?.mmsi || item?.id) === String(subject.id),
|
String(item?.icao24 || item?.id || item?.mmsi) === String(subject.id),
|
||||||
) || subject
|
) || subject
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function createPanel({ state: layerState, parts }) {
|
|||||||
.slice(page, page + AWARENESS_PAGE_SIZE)
|
.slice(page, page + AWARENESS_PAGE_SIZE)
|
||||||
.map((item, index) => {
|
.map((item, index) => {
|
||||||
const label = formatAwarenessLabel(item);
|
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);
|
const distance = presentAwarenessDistance(item.distanceM);
|
||||||
if (!targetId)
|
if (!targetId)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
|||||||
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
|
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(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 coverage = installationsState.stats?.coverage;
|
||||||
const installationRadius =
|
const installationRadius =
|
||||||
@@ -519,7 +519,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
|||||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||||
return {
|
return {
|
||||||
layerId: record.layerId,
|
layerId: record.layerId,
|
||||||
id: record.properties?.mmsi || record.id,
|
id: record.properties?.vesselId || record.id,
|
||||||
label: record.label || record.id,
|
label: record.label || record.id,
|
||||||
memberNames: record.properties?.memberNames || [],
|
memberNames: record.properties?.memberNames || [],
|
||||||
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
|
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function createCards({
|
|||||||
parts.push(`${roundHeadingDegrees(direction)}°`);
|
parts.push(`${roundHeadingDegrees(direction)}°`);
|
||||||
return {
|
return {
|
||||||
id: vesselOverlayEntryId(record),
|
id: vesselOverlayEntryId(record),
|
||||||
actionable: Boolean(record?.mmsi),
|
actionable: Boolean(record?.id),
|
||||||
position:
|
position:
|
||||||
components.rendering.getVisual(record).billboard?.position ||
|
components.rendering.getVisual(record).billboard?.position ||
|
||||||
components.rendering.getVisual(record).position,
|
components.rendering.getVisual(record).position,
|
||||||
@@ -101,7 +101,7 @@ export function createCards({
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
id: vesselOverlayEntryId(record),
|
id: vesselOverlayEntryId(record),
|
||||||
actionable: Boolean(record?.mmsi),
|
actionable: Boolean(record?.id),
|
||||||
position:
|
position:
|
||||||
components.rendering.getVisual(record).billboard?.position ||
|
components.rendering.getVisual(record).billboard?.position ||
|
||||||
components.rendering.getVisual(record).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) {
|
function vesselOverlayEntryId(record) {
|
||||||
const mmsi = String(record?.mmsi || '').trim();
|
const id = String(record?.id || '').trim();
|
||||||
if (mmsi) return `vessel:${mmsi}`;
|
if (id) return `vessel:${id}`;
|
||||||
const name = String(record?.name || 'VESSEL').trim() || 'VESSEL';
|
const name = String(record?.name || 'VESSEL').trim() || 'VESSEL';
|
||||||
const lat = Number.isFinite(record?.lat) ? record.lat.toFixed(5) : 'x';
|
const lat = Number.isFinite(record?.lat) ? record.lat.toFixed(5) : 'x';
|
||||||
const lon = Number.isFinite(record?.lon) ? record.lon.toFixed(5) : 'x';
|
const lon = Number.isFinite(record?.lon) ? record.lon.toFixed(5) : 'x';
|
||||||
@@ -161,7 +161,9 @@ export function createCards({
|
|||||||
|
|
||||||
function displayVesselName(record) {
|
function displayVesselName(record) {
|
||||||
const name = String(record.name || '').trim();
|
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');
|
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', () => {
|
test('rows at another time stand in for every vessel, selected or not', () => {
|
||||||
const probe = store();
|
const probe = store();
|
||||||
probe.reconcile([row('111'), row('222')]);
|
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 })], {
|
probe.reconcile([row('111', { last_position_epoch: 900 })], {
|
||||||
complete: false,
|
complete: false,
|
||||||
selectedRecord,
|
selectedRecord,
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
assert.deepEqual([...probe.records.byMmsi.keys()], ['111']);
|
assert.deepEqual([...probe.records.byId.keys()], ['111']);
|
||||||
assert.deepEqual(probe.events.slice(-3), [
|
assert.deepEqual(probe.events.slice(-3), [
|
||||||
['update', '111'],
|
['update', '111'],
|
||||||
['remove', '222', true],
|
['remove', '222', true],
|
||||||
['removed', '222'],
|
['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', () => {
|
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 })], {
|
probe.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })], {
|
||||||
follow: true,
|
follow: true,
|
||||||
});
|
});
|
||||||
const record = probe.records.byMmsi.get('111');
|
const record = probe.records.byId.get('111');
|
||||||
assert.deepEqual(record.motion, {
|
assert.deepEqual(record.motion, {
|
||||||
fromLat: 51.93,
|
fromLat: 51.93,
|
||||||
fromLon: 4.05,
|
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')]);
|
||||||
live.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })]);
|
live.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })]);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
Object.hasOwn(live.records.byMmsi.get('111'), 'motion'),
|
Object.hasOwn(live.records.byId.get('111'), 'motion'),
|
||||||
false,
|
false,
|
||||||
'live records are as before',
|
'live records are as before',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ function finiteOrNull(value) {
|
|||||||
* @returns {object|null} Subject info, or null without a usable fix.
|
* @returns {object|null} Subject info, or null without a usable fix.
|
||||||
*/
|
*/
|
||||||
export function vesselCockpitInfo(record) {
|
export function vesselCockpitInfo(record) {
|
||||||
if (!record?.mmsi) return null;
|
if (!record?.id) return null;
|
||||||
const latitude = Number(record.lat);
|
const latitude = Number(record.lat);
|
||||||
const longitude = Number(record.lon);
|
const longitude = Number(record.lon);
|
||||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||||
@@ -111,9 +111,9 @@ export function vesselCockpitInfo(record) {
|
|||||||
return {
|
return {
|
||||||
kind: 'vessel',
|
kind: 'vessel',
|
||||||
layerId: VESSEL_COCKPIT_LAYER_ID,
|
layerId: VESSEL_COCKPIT_LAYER_ID,
|
||||||
id: String(record.mmsi),
|
id: String(record.id),
|
||||||
mmsi: String(record.mmsi),
|
mmsi: String(record.mmsi || ''),
|
||||||
name: String(record.name || record.mmsi),
|
name: String(record.name || record.mmsi || record.id),
|
||||||
type: String(record.type || ''),
|
type: String(record.type || ''),
|
||||||
typeLabel: displayVesselType(record.type),
|
typeLabel: displayVesselType(record.type),
|
||||||
destination: String(record.destination || '').trim(),
|
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', () => {
|
test('vessel info faces the true heading and moves along course over ground', () => {
|
||||||
const info = vesselCockpitInfo({
|
const info = vesselCockpitInfo({
|
||||||
|
id: '211000000',
|
||||||
mmsi: '211000000',
|
mmsi: '211000000',
|
||||||
name: 'NORDIC STAR',
|
name: 'NORDIC STAR',
|
||||||
type: '70',
|
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.kind, 'vessel');
|
||||||
assert.equal(info.layerId, VESSEL_COCKPIT_LAYER_ID);
|
assert.equal(info.layerId, VESSEL_COCKPIT_LAYER_ID);
|
||||||
assert.equal(info.id, '211000000');
|
assert.equal(info.id, '211000000');
|
||||||
|
assert.equal(info.mmsi, '211000000');
|
||||||
assert.equal(info.typeLabel, 'CARGO');
|
assert.equal(info.typeLabel, 'CARGO');
|
||||||
assert.equal(info.track, 90);
|
assert.equal(info.track, 90);
|
||||||
assert.equal(info.motionCourse, 95);
|
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', () => {
|
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, course: 40 }).track, 40);
|
||||||
assert.equal(vesselCockpitInfo({ ...base, heading: 50 }).motionCourse, 50);
|
assert.equal(vesselCockpitInfo({ ...base, heading: 50 }).motionCourse, 50);
|
||||||
const neither = vesselCockpitInfo(base);
|
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', () => {
|
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(
|
assert.equal(
|
||||||
vesselCockpitInfo({ ...base, heading: 50, course: 40 }).trackSource,
|
vesselCockpitInfo({ ...base, heading: 50, course: 40 }).trackSource,
|
||||||
'heading',
|
'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', () => {
|
test('a vessel without identity or position is not a subject', () => {
|
||||||
assert.equal(vesselCockpitInfo(null), null);
|
assert.equal(vesselCockpitInfo(null), null);
|
||||||
assert.equal(vesselCockpitInfo({ lat: 1, lon: 1 }), 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', () => {
|
test('a vessel missing from recent refreshes reads as stale', () => {
|
||||||
const info = vesselCockpitInfo({
|
const info = vesselCockpitInfo({
|
||||||
|
id: '1',
|
||||||
mmsi: '1',
|
mmsi: '1',
|
||||||
lat: 1,
|
lat: 1,
|
||||||
lon: 1,
|
lon: 1,
|
||||||
@@ -111,3 +114,20 @@ test('a vessel missing from recent refreshes reads as stale', () => {
|
|||||||
});
|
});
|
||||||
assert.equal(info.stale, true);
|
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;
|
: null;
|
||||||
return {
|
return {
|
||||||
id: record.mmsi,
|
id: record.id,
|
||||||
show: bb?.show === true,
|
show: bb?.show === true,
|
||||||
alpha: bb?.color?.alpha ?? null,
|
alpha: bb?.color?.alpha ?? null,
|
||||||
x: screen?.x ?? null,
|
x: screen?.x ?? null,
|
||||||
|
|||||||
@@ -201,9 +201,11 @@ export function createIngestion({
|
|||||||
return { reconciled: true, ...snapshot };
|
return { reconciled: true, ...snapshot };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The layer's row for a source record: keyed by its id, MMSI shown when known. */
|
||||||
function vesselDisplayRow(record) {
|
function vesselDisplayRow(record) {
|
||||||
return {
|
return {
|
||||||
mmsi: record.id,
|
id: record.id,
|
||||||
|
mmsi: String(record.mmsi ?? '').trim(),
|
||||||
reference: record.reference,
|
reference: record.reference,
|
||||||
lat: record.latitude,
|
lat: record.latitude,
|
||||||
lon: record.longitude,
|
lon: record.longitude,
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function createLifecycle({
|
|||||||
state.feed.abort = null;
|
state.feed.abort = null;
|
||||||
state.billboardCollection = null;
|
state.billboardCollection = null;
|
||||||
state.records.all = [];
|
state.records.all = [];
|
||||||
state.records.byMmsi = new Map();
|
state.records.byId = new Map();
|
||||||
state.records.unkeyed = [];
|
state.records.unkeyed = [];
|
||||||
state.clickHandler = null;
|
state.clickHandler = null;
|
||||||
state.keyTarget = null;
|
state.keyTarget = null;
|
||||||
@@ -133,7 +133,7 @@ export function createLifecycle({
|
|||||||
state.selectedRecord = null;
|
state.selectedRecord = null;
|
||||||
state.trail = null;
|
state.trail = null;
|
||||||
state.trailPositions = [];
|
state.trailPositions = [];
|
||||||
state.trailMmsi = null;
|
state.trailId = null;
|
||||||
state.trailBackfillToken = 0;
|
state.trailBackfillToken = 0;
|
||||||
}
|
}
|
||||||
const methods = {
|
const methods = {
|
||||||
@@ -188,9 +188,9 @@ export function createLifecycle({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Pick-ownership (H2): vessel picks carry the record OBJECT as their id;
|
// 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) =>
|
registerPickOwner('ais-live-vessels', (pickedId) =>
|
||||||
state.records.byMmsi.has(pickedId),
|
state.records.byId.has(pickedId),
|
||||||
);
|
);
|
||||||
restoreSpriteOrderOnEnable('ais', activeViewer);
|
restoreSpriteOrderOnEnable('ais', activeViewer);
|
||||||
// Vessels are asked for by view, so ask again when the view moves away.
|
// 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() });
|
const two = createVesselState({ source, services: services() });
|
||||||
for (const key of ['state', 'shipIconCache', '_scratchFocusScreen'])
|
for (const key of ['state', 'shipIconCache', '_scratchFocusScreen'])
|
||||||
assert.notEqual(one[key], two[key]);
|
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.feed, two.state.feed);
|
||||||
assert.notEqual(one.state.records.all, two.state.records.all);
|
assert.notEqual(one.state.records.all, two.state.records.all);
|
||||||
assert.notEqual(one.state.records.unkeyed, two.state.records.unkeyed);
|
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');
|
layer.selectById('222');
|
||||||
current = snapshot([observation('111')], { complete: false });
|
current = snapshot([observation('111')], { complete: false });
|
||||||
await layer.update();
|
await layer.update();
|
||||||
assert.equal(layer.getSelectedInfo().mmsi, '222');
|
assert.equal(layer.getSelectedInfo().id, '222');
|
||||||
assert.equal(signal.aborted, false);
|
assert.equal(signal.aborted, false);
|
||||||
advance(5 * 60 * 1000);
|
advance(5 * 60 * 1000);
|
||||||
await layer.update();
|
await layer.update();
|
||||||
@@ -421,3 +421,97 @@ test('vessel stats name the provider the last snapshot reported', async () => {
|
|||||||
await layer.update();
|
await layer.update();
|
||||||
assert.equal(layer.getStats().source, 'Datalastic');
|
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';
|
} from './policy.js';
|
||||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.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({
|
export function createQueries({
|
||||||
vesselState,
|
vesselState,
|
||||||
services,
|
services,
|
||||||
@@ -38,6 +41,28 @@ export function createQueries({
|
|||||||
const { state } = vesselState;
|
const { state } = vesselState;
|
||||||
const { formatKnots } = services.labels;
|
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
|
* Seconds until the server's next reconnect attempt, or 0 when none is
|
||||||
* scheduled. Mirrors the flights layer's `retryInSec` chip affordance.
|
* 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
|
* fields are null, never NaN/undefined. navStatus is always null: the
|
||||||
* /api/vessels proxy does not surface AIS NavigationalStatus, so it
|
* /api/vessels proxy does not surface AIS NavigationalStatus, so it
|
||||||
* cannot be derived client-side.
|
* cannot be derived client-side.
|
||||||
* @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry.
|
* @param {Object|null|undefined} record - `state.records.byId`/`state.records.all` entry.
|
||||||
* @returns {{id: string|null, mmsi: string|null, name: string|null,
|
* @returns {{id: string|null, vesselId: string|null, mmsi: string|null,
|
||||||
|
* name: string|null,
|
||||||
* lat: number|null, lon: number|null, speedKts: number|null,
|
* lat: number|null, lon: number|null, speedKts: number|null,
|
||||||
* courseDeg: number|null, shipType: string|null, destination: string|null,
|
* courseDeg: number|null, shipType: string|null, destination: string|null,
|
||||||
* navStatus: null}}
|
* navStatus: null}}
|
||||||
@@ -188,10 +214,14 @@ export function createQueries({
|
|||||||
const t = String(v ?? '').trim();
|
const t = String(v ?? '').trim();
|
||||||
return t || null;
|
return t || null;
|
||||||
};
|
};
|
||||||
|
const vesselId = text(record?.id);
|
||||||
const mmsi = text(record?.mmsi);
|
const mmsi = text(record?.mmsi);
|
||||||
const name = text(record?.name);
|
const name = text(record?.name);
|
||||||
return {
|
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,
|
mmsi,
|
||||||
name,
|
name,
|
||||||
lat: num(record?.lat),
|
lat: num(record?.lat),
|
||||||
@@ -225,32 +255,32 @@ export function createQueries({
|
|||||||
* trail picks as no-ops. The interaction wire also reserves sibling-owned
|
* trail picks as no-ops. The interaction wire also reserves sibling-owned
|
||||||
* picks before this reducer so their camera action cannot mutate AIS state.
|
* 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.
|
* gesture?: 'click'|'escape'}} input - Current selection plus owned pick.
|
||||||
* @returns {{action: 'none'|'select'|'deselect'}}
|
* @returns {{action: 'none'|'select'|'deselect'}}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function reduceVesselSelection(input = {}) {
|
function reduceVesselSelection(input = {}) {
|
||||||
const selectedMmsi = normalizeSelectionMmsi(input.selectedMmsi);
|
const selectedId = normalizeSelectionId(input.selectedId);
|
||||||
const pickedMmsi = normalizeSelectionMmsi(input.pickedMmsi);
|
const pickedId = normalizeSelectionId(input.pickedId);
|
||||||
const gesture = input.gesture || 'click';
|
const gesture = input.gesture || 'click';
|
||||||
|
|
||||||
if (gesture === 'escape') {
|
if (gesture === 'escape') {
|
||||||
return selectedMmsi ? { action: 'deselect' } : { action: 'none' };
|
return selectedId ? { action: 'deselect' } : { action: 'none' };
|
||||||
}
|
}
|
||||||
if (gesture !== 'click') {
|
if (gesture !== 'click') {
|
||||||
return { action: 'none' };
|
return { action: 'none' };
|
||||||
}
|
}
|
||||||
if (pickedMmsi) {
|
if (pickedId) {
|
||||||
if (pickedMmsi === selectedMmsi) {
|
if (pickedId === selectedId) {
|
||||||
return { action: 'none' };
|
return { action: 'none' };
|
||||||
}
|
}
|
||||||
return { action: 'select' };
|
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;
|
if (value === null || value === undefined) return null;
|
||||||
const text = String(value).trim();
|
const text = String(value).trim();
|
||||||
return text || null;
|
return text || null;
|
||||||
@@ -275,9 +305,10 @@ export function createQueries({
|
|||||||
statsRefreshInterval: 1000,
|
statsRefreshInterval: 1000,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a vessel by exact MMSI or case-insensitive name substring.
|
* Find a vessel by its exact id, exact MMSI or case-insensitive name
|
||||||
* @param {string|number} query MMSI or partial vessel name.
|
* substring. Vessels can share an MMSI; the first that has it is found.
|
||||||
* @returns {{ mmsi: string, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
|
* @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) {
|
findByQuery(query) {
|
||||||
if (query === null || query === undefined) return null;
|
if (query === null || query === undefined) return null;
|
||||||
@@ -286,9 +317,9 @@ export function createQueries({
|
|||||||
const q = String(query).trim();
|
const q = String(query).trim();
|
||||||
if (!q) return null;
|
if (!q) return null;
|
||||||
|
|
||||||
let record = null;
|
let record = state.records.byId.get(q) || null;
|
||||||
if (/^\d+$/.test(q)) {
|
if (!record && /^\d+$/.test(q)) {
|
||||||
record = state.records.byMmsi.get(q) || null;
|
record = records.find((r) => r.mmsi === q) || null;
|
||||||
}
|
}
|
||||||
if (!record) {
|
if (!record) {
|
||||||
const lower = q.toLowerCase();
|
const lower = q.toLowerCase();
|
||||||
@@ -306,6 +337,7 @@ export function createQueries({
|
|||||||
components.rendering.getVisual(record).position;
|
components.rendering.getVisual(record).position;
|
||||||
if (!position) return null;
|
if (!position) return null;
|
||||||
return {
|
return {
|
||||||
|
id: record.id,
|
||||||
mmsi: record.mmsi,
|
mmsi: record.mmsi,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
position,
|
position,
|
||||||
@@ -322,7 +354,7 @@ export function createQueries({
|
|||||||
* @param {Cesium.Cartesian3} centerCartesian Center of the search.
|
* @param {Cesium.Cartesian3} centerCartesian Center of the search.
|
||||||
* @param {number} rangeM Max distance in meters (non-finite = unbounded).
|
* @param {number} rangeM Max distance in meters (non-finite = unbounded).
|
||||||
* @param {number} [maxCount=25] Maximum entries to return.
|
* @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) {
|
getNearby(centerCartesian, rangeM, maxCount = 25) {
|
||||||
const records = state.records.all;
|
const records = state.records.all;
|
||||||
@@ -342,6 +374,7 @@ export function createQueries({
|
|||||||
const distanceM = Cesium.Cartesian3.distance(centerCartesian, position);
|
const distanceM = Cesium.Cartesian3.distance(centerCartesian, position);
|
||||||
if (!Number.isFinite(distanceM) || distanceM > range) continue;
|
if (!Number.isFinite(distanceM) || distanceM > range) continue;
|
||||||
entries.push({
|
entries.push({
|
||||||
|
id: record.id,
|
||||||
mmsi: record.mmsi,
|
mmsi: record.mmsi,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
position,
|
position,
|
||||||
@@ -363,7 +396,7 @@ export function createQueries({
|
|||||||
* @returns {Array<{ id: string, name: string, latitude: number,
|
* @returns {Array<{ id: string, name: string, latitude: number,
|
||||||
* longitude: number, course: number|null, heading: number|null,
|
* longitude: number, course: number|null, heading: number|null,
|
||||||
* type: string, color: string, stale: boolean }>} Empty while the
|
* 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) {
|
getRadarContacts(lat, lon, rangeM, maxCount = 60) {
|
||||||
const records = state.records.all;
|
const records = state.records.all;
|
||||||
@@ -373,7 +406,7 @@ export function createQueries({
|
|||||||
const cap =
|
const cap =
|
||||||
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 60;
|
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 60;
|
||||||
const center = { lat, lon };
|
const center = { lat, lon };
|
||||||
const subject = state.cockpitSubjectMmsi;
|
const subject = state.cockpitSubjectId;
|
||||||
const finite = (value) => {
|
const finite = (value) => {
|
||||||
if (value === null || value === undefined || value === '') return null;
|
if (value === null || value === undefined || value === '') return null;
|
||||||
const number = Number(value);
|
const number = Number(value);
|
||||||
@@ -382,7 +415,7 @@ export function createQueries({
|
|||||||
|
|
||||||
const entries = [];
|
const entries = [];
|
||||||
records.forEach((record, index) => {
|
records.forEach((record, index) => {
|
||||||
if (subject && String(record.mmsi) === subject) return;
|
if (subject && String(record.id) === subject) return;
|
||||||
const distanceM = greatCircleMeters(center, {
|
const distanceM = greatCircleMeters(center, {
|
||||||
lat: record.lat,
|
lat: record.lat,
|
||||||
lon: record.lon,
|
lon: record.lon,
|
||||||
@@ -392,8 +425,8 @@ export function createQueries({
|
|||||||
});
|
});
|
||||||
entries.sort((a, b) => a.distanceM - b.distanceM);
|
entries.sort((a, b) => a.distanceM - b.distanceM);
|
||||||
return entries.slice(0, cap).map(({ record, index }) => ({
|
return entries.slice(0, cap).map(({ record, index }) => ({
|
||||||
// A ship with no MMSI still needs a key of its own on the scope.
|
// A ship with no id still needs a key of its own on the scope.
|
||||||
id: record.mmsi ? String(record.mmsi) : `unkeyed:${index}`,
|
id: record.id ? String(record.id) : `unkeyed:${index}`,
|
||||||
name: String(record.name || record.mmsi || ''),
|
name: String(record.name || record.mmsi || ''),
|
||||||
latitude: record.lat,
|
latitude: record.lat,
|
||||||
longitude: record.lon,
|
longitude: record.lon,
|
||||||
@@ -414,22 +447,22 @@ export function createQueries({
|
|||||||
* Whether this layer still carries a vessel, in O(1).
|
* Whether this layer still carries a vessel, in O(1).
|
||||||
*
|
*
|
||||||
* Mirror of `flights.hasContact`: presence consumers must not infer absence
|
* 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
|
* A disabled layer keeps its records, so it must decline rather than answer
|
||||||
* from data the user can no longer see.
|
* 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
|
* @returns {boolean|null} Presence, or null when the layer is disabled or
|
||||||
* holds no data and therefore cannot answer.
|
* holds no data and therefore cannot answer.
|
||||||
*/
|
*/
|
||||||
hasContact(mmsi) {
|
hasContact(id) {
|
||||||
if (
|
if (
|
||||||
!state.feed.enabled ||
|
!state.feed.enabled ||
|
||||||
!state.records.byMmsi ||
|
!state.records.byId ||
|
||||||
state.records.byMmsi.size === 0
|
state.records.byId.size === 0
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
if (!mmsi) return false;
|
if (!id) return false;
|
||||||
return state.records.byMmsi.has(String(mmsi).trim());
|
return Boolean(recordFor(String(id).trim()));
|
||||||
},
|
},
|
||||||
|
|
||||||
getAllPositions(maxCount = 800) {
|
getAllPositions(maxCount = 800) {
|
||||||
@@ -445,8 +478,8 @@ export function createQueries({
|
|||||||
const position = visual.billboard?.position || visual.position;
|
const position = visual.billboard?.position || visual.position;
|
||||||
if (!position) continue;
|
if (!position) continue;
|
||||||
result.push({
|
result.push({
|
||||||
id: record.mmsi,
|
id: record.id,
|
||||||
label: record.name || record.mmsi,
|
label: record.name || record.mmsi || record.id,
|
||||||
position,
|
position,
|
||||||
latitude: record.lat,
|
latitude: record.lat,
|
||||||
longitude: record.lon,
|
longitude: record.lon,
|
||||||
@@ -479,16 +512,16 @@ export function createQueries({
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a vessel by MMSI via the same path as a map click
|
* Select a vessel by id via the same path as a map click
|
||||||
* (highlight + HUD update).
|
* (highlight + HUD update). An AIS feed's vessels are keyed by MMSI.
|
||||||
* @param {string|number} mmsi Vessel MMSI.
|
* @param {string|number} id Vessel id.
|
||||||
* @returns {boolean} True if a matching vessel was selected.
|
* @returns {boolean} True if a matching vessel was selected.
|
||||||
*/
|
*/
|
||||||
selectById(mmsi) {
|
selectById(id) {
|
||||||
if (mmsi === null || mmsi === undefined) return false;
|
if (id === null || id === undefined) return false;
|
||||||
const target = String(mmsi).trim();
|
const target = String(id).trim();
|
||||||
if (!target) return false;
|
if (!target) return false;
|
||||||
const record = state.records.byMmsi.get(target);
|
const record = recordFor(target);
|
||||||
if (!record) return false;
|
if (!record) return false;
|
||||||
components.selection.selectVessel(record);
|
components.selection.selectVessel(record);
|
||||||
return true;
|
return true;
|
||||||
@@ -505,12 +538,13 @@ export function createQueries({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get info about the currently selected vessel.
|
* 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() {
|
getSelectedInfo() {
|
||||||
const record = state.selectedRecord;
|
const record = state.selectedRecord;
|
||||||
if (!record) return null;
|
if (!record) return null;
|
||||||
return {
|
return {
|
||||||
|
id: record.id,
|
||||||
mmsi: record.mmsi,
|
mmsi: record.mmsi,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
latitude: record.lat,
|
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.
|
* Describe the selected vessel for the Cockpit bridge view.
|
||||||
* @returns {object|null} `vesselCockpitInfo` fields plus `seaSurfaceHeightM`
|
* @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.
|
* Mark the vessel whose bridge the Cockpit camera occupies, or release it.
|
||||||
* Its sprite, card and detection bracket stay hidden while it is held.
|
* 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.
|
* @returns {boolean} Always true.
|
||||||
*/
|
*/
|
||||||
setCockpitSubject(mmsi) {
|
setCockpitSubject(id) {
|
||||||
const next = normalizeSelectionMmsi(mmsi);
|
const next = normalizeSelectionId(id);
|
||||||
if (state.cockpitSubjectMmsi === next) return true;
|
if (state.cockpitSubjectId === next) return true;
|
||||||
state.cockpitSubjectMmsi = next;
|
state.cockpitSubjectId = next;
|
||||||
// The ridden ship's trail runs from under the eye: hidden, its history
|
// The ridden ship's trail runs from under the eye: hidden, its history
|
||||||
// kept, while the bridge holds the ship it belongs to.
|
// 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);
|
components.rendering.updateVisibility(true);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -608,7 +669,7 @@ export function createQueries({
|
|||||||
if (!position) continue;
|
if (!position) continue;
|
||||||
result.push({
|
result.push({
|
||||||
position,
|
position,
|
||||||
sourceId: record.mmsi,
|
sourceId: record.id,
|
||||||
id: record.name || record.mmsi || 'VESSEL',
|
id: record.name || record.mmsi || 'VESSEL',
|
||||||
type: 'SEA',
|
type: 'SEA',
|
||||||
skipLabel: record === selected,
|
skipLabel: record === selected,
|
||||||
@@ -676,7 +737,7 @@ export function createQueries({
|
|||||||
mapAnalystRecord,
|
mapAnalystRecord,
|
||||||
vesselDatumHeightM,
|
vesselDatumHeightM,
|
||||||
reduceVesselSelection,
|
reduceVesselSelection,
|
||||||
normalizeSelectionMmsi,
|
normalizeSelectionId,
|
||||||
methods,
|
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)], {
|
const layer = layerWith([record('own', 0), record('other', 2)], {
|
||||||
trail,
|
trail,
|
||||||
trailMmsi: 'own',
|
trailId: 'own',
|
||||||
});
|
});
|
||||||
layer.setCockpitSubject('own');
|
layer.setCockpitSubject('own');
|
||||||
assert.deepEqual(shown, [false]);
|
assert.deepEqual(shown, [false]);
|
||||||
|
|||||||
@@ -3,17 +3,25 @@ import {
|
|||||||
SELECTED_PIN_REFRESHES,
|
SELECTED_PIN_REFRESHES,
|
||||||
} from './recordPolicy.js';
|
} 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) {
|
export function normalizeVessel(row) {
|
||||||
const lat = Number(row.lat);
|
const lat = Number(row.lat);
|
||||||
const lon = Number(row.lon);
|
const lon = Number(row.lon);
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||||
|
const mmsi = String(row.mmsi || '').trim();
|
||||||
|
const id = String(row.id ?? mmsi).trim();
|
||||||
return {
|
return {
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
name: String(row.name || row.mmsi || 'VESSEL'),
|
name: String(row.name || mmsi || 'VESSEL'),
|
||||||
mmsi: String(row.mmsi || '').trim(),
|
id,
|
||||||
reference: row.reference ?? String(row.mmsi || '').trim(),
|
mmsi,
|
||||||
|
reference: row.reference ?? id,
|
||||||
imo: String(row.imo || ''),
|
imo: String(row.imo || ''),
|
||||||
type: String(row.type || ''),
|
type: String(row.type || ''),
|
||||||
destination: String(row.destination || ''),
|
destination: String(row.destination || ''),
|
||||||
@@ -68,7 +76,7 @@ function motionBetween(record, next) {
|
|||||||
export class VesselRecords {
|
export class VesselRecords {
|
||||||
constructor({ now = () => Date.now() } = {}) {
|
constructor({ now = () => Date.now() } = {}) {
|
||||||
this.now = now;
|
this.now = now;
|
||||||
this.byMmsi = new Map();
|
this.byId = new Map();
|
||||||
this.unkeyed = [];
|
this.unkeyed = [];
|
||||||
this.all = [];
|
this.all = [];
|
||||||
}
|
}
|
||||||
@@ -112,14 +120,14 @@ export class VesselRecords {
|
|||||||
if (!next) continue;
|
if (!next) continue;
|
||||||
next.receivedAtMs = receivedAtMs;
|
next.receivedAtMs = receivedAtMs;
|
||||||
stampFix(next, receivedAtMs, feedLatestEpoch);
|
stampFix(next, receivedAtMs, feedLatestEpoch);
|
||||||
if (!next.mmsi) {
|
if (!next.id) {
|
||||||
effects.add(next);
|
effects.add(next);
|
||||||
this.unkeyed.push(next);
|
this.unkeyed.push(next);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (seen.has(next.mmsi)) continue;
|
if (seen.has(next.id)) continue;
|
||||||
seen.add(next.mmsi);
|
seen.add(next.id);
|
||||||
const record = this.byMmsi.get(next.mmsi);
|
const record = this.byId.get(next.id);
|
||||||
if (record) {
|
if (record) {
|
||||||
const before = effects.beforeUpdate(record);
|
const before = effects.beforeUpdate(record);
|
||||||
// A feed re-serving a cached row repeats its fix: the fix keeps the
|
// 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;
|
if (replace) record.motion = null;
|
||||||
else if (follow) record.motion = motionBetween(record, next);
|
else if (follow) record.motion = motionBetween(record, next);
|
||||||
record.reference = next.reference;
|
record.reference = next.reference;
|
||||||
|
record.mmsi = next.mmsi;
|
||||||
record.receivedAtMs = next.receivedAtMs;
|
record.receivedAtMs = next.receivedAtMs;
|
||||||
record.lat = next.lat;
|
record.lat = next.lat;
|
||||||
record.lon = next.lon;
|
record.lon = next.lon;
|
||||||
@@ -148,15 +157,15 @@ export class VesselRecords {
|
|||||||
effects.updated(record, before);
|
effects.updated(record, before);
|
||||||
} else {
|
} else {
|
||||||
effects.add(next);
|
effects.add(next);
|
||||||
this.byMmsi.set(next.mmsi, next);
|
this.byId.set(next.id, next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [mmsi, record] of this.byMmsi) {
|
for (const [id, record] of this.byId) {
|
||||||
if (seen.has(mmsi)) continue;
|
if (seen.has(id)) continue;
|
||||||
if (replace) {
|
if (replace) {
|
||||||
effects.remove(record, record === selectedRecord);
|
effects.remove(record, record === selectedRecord);
|
||||||
this.byMmsi.delete(mmsi);
|
this.byId.delete(id);
|
||||||
effects.removed(mmsi);
|
effects.removed(id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -179,18 +188,18 @@ export class VesselRecords {
|
|||||||
}
|
}
|
||||||
// Selection teardown still sees the record before its store entry is removed.
|
// Selection teardown still sees the record before its store entry is removed.
|
||||||
effects.remove(record, record === selectedRecord);
|
effects.remove(record, record === selectedRecord);
|
||||||
this.byMmsi.delete(mmsi);
|
this.byId.delete(id);
|
||||||
effects.removed(mmsi);
|
effects.removed(id);
|
||||||
}
|
}
|
||||||
if (this.byMmsi.size + this.unkeyed.length > cap) {
|
if (this.byId.size + this.unkeyed.length > cap) {
|
||||||
for (const [mmsi, record] of this.byMmsi) {
|
for (const [id, record] of this.byId) {
|
||||||
if (this.byMmsi.size + this.unkeyed.length <= cap) break;
|
if (this.byId.size + this.unkeyed.length <= cap) break;
|
||||||
if (seen.has(mmsi) || record === selectedRecord) continue;
|
if (seen.has(id) || record === selectedRecord) continue;
|
||||||
effects.remove(record, false);
|
effects.remove(record, false);
|
||||||
this.byMmsi.delete(mmsi);
|
this.byId.delete(id);
|
||||||
effects.removed(mmsi);
|
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,
|
beforeUpdate: (record) => record.type,
|
||||||
updated: (record, before) => events.push(['update', record, before]),
|
updated: (record, before) => events.push(['update', record, before]),
|
||||||
remove: (record, evicted) =>
|
remove: (record, evicted) =>
|
||||||
events.push(['remove', record, evicted, store.byMmsi.has(record.mmsi)]),
|
events.push(['remove', record, evicted, store.byId.has(record.id)]),
|
||||||
removed: (id) => events.push(['removed', id, store.byMmsi.has(id)]),
|
removed: (id) => events.push(['removed', id, store.byId.has(id)]),
|
||||||
staleSelected: (record) => events.push(['stale', record]),
|
staleSelected: (record) => events.push(['stale', record]),
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
@@ -40,14 +40,14 @@ test('vessel reconciliation preserves record identity and first duplicate while
|
|||||||
row('222', { name: 'duplicate' }),
|
row('222', { name: 'duplicate' }),
|
||||||
row('bad', { lat: NaN }),
|
row('bad', { lat: NaN }),
|
||||||
]);
|
]);
|
||||||
assert.equal(probe.store.byMmsi.size, 2);
|
assert.equal(probe.store.byId.size, 2);
|
||||||
assert.equal(probe.store.byMmsi.get('222').name, '222');
|
assert.equal(probe.store.byId.get('222').name, '222');
|
||||||
const record = probe.store.byMmsi.get('111');
|
const record = probe.store.byId.get('111');
|
||||||
probe.reconcile([row('111', { lat: 51.94, type: 'Tanker' })]);
|
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(record.lat, 51.94);
|
||||||
assert.equal(probe.events.find((event) => event[0] === 'update')[2], 'Cargo');
|
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'])
|
for (const field of ['position', 'surfacePosition', 'normal', 'billboard'])
|
||||||
assert.equal(Object.hasOwn(record, field), false);
|
assert.equal(Object.hasOwn(record, field), false);
|
||||||
assert.deepEqual(structuredClone(record), record);
|
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', () => {
|
test('selected vessel remains pinned for three complete misses and teardown precedes removal', () => {
|
||||||
const probe = setup();
|
const probe = setup();
|
||||||
probe.reconcile([row('111'), row('222')]);
|
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++) {
|
for (let i = 1; i <= 3; i++) {
|
||||||
probe.reconcile([row('222')], { selectedRecord });
|
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);
|
assert.equal(selectedRecord.missedRefreshes, i);
|
||||||
}
|
}
|
||||||
probe.reconcile([row('222')], { selectedRecord });
|
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(
|
const removal = probe.events.find(
|
||||||
(event) => event[0] === 'remove' && event[1] === selectedRecord,
|
(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', () => {
|
test('partial retention respects age and row cap while preserving the selected contact', () => {
|
||||||
const probe = setup();
|
const probe = setup();
|
||||||
probe.reconcile([row('111'), row('222')], { cap: 2 });
|
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 });
|
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);
|
assert.equal(selectedRecord.missedRefreshes, 1);
|
||||||
probe.advance(5 * 60 * 1000);
|
probe.advance(5 * 60 * 1000);
|
||||||
probe.reconcile([row('333')], { complete: false, selectedRecord, cap: 2 });
|
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(
|
assert.equal(
|
||||||
probe.events.find(
|
probe.events.find(
|
||||||
(event) => event[0] === 'remove' && event[1] === selectedRecord,
|
(event) => event[0] === 'remove' && event[1] === selectedRecord,
|
||||||
@@ -107,7 +107,7 @@ test('unkeyed records rebuild on each refresh and numeric coercion keeps unknown
|
|||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(probe.store.byMmsi.size, 0);
|
assert.equal(probe.store.byId.size, 0);
|
||||||
assert.equal(probe.store.all.length, 1);
|
assert.equal(probe.store.all.length, 1);
|
||||||
assert.equal(normalizeVessel(row('bad', { lon: Infinity })), null);
|
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'),
|
row('333'),
|
||||||
]);
|
]);
|
||||||
for (const mmsi of ['111', '222', '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.fixFeedEpoch, 1_800_000_030, mmsi);
|
||||||
assert.equal(record.fixReceivedAtMs, 1000, mmsi);
|
assert.equal(record.fixReceivedAtMs, 1000, mmsi);
|
||||||
}
|
}
|
||||||
// A new fix is stamped afresh.
|
// A new fix is stamped afresh.
|
||||||
probe.advance(60_000);
|
probe.advance(60_000);
|
||||||
probe.reconcile([row('111', { last_position_epoch: 1_800_000_050 })]);
|
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.fixFeedEpoch, 1_800_000_050);
|
||||||
assert.equal(record.fixReceivedAtMs, 61_000);
|
assert.equal(record.fixReceivedAtMs, 61_000);
|
||||||
// The same fix re-served by later polls keeps its first receipt, while
|
// 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.fixFeedEpoch, 1_800_000_050);
|
||||||
assert.equal(record.receivedAtMs, 91_000);
|
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) {
|
if (visual.billboard) {
|
||||||
// The bridge camera sits on this sprite; showing it would fill the view.
|
// The bridge camera sits on this sprite; showing it would fill the view.
|
||||||
visual.billboard.show =
|
visual.billboard.show =
|
||||||
visible && record.mmsi !== state.cockpitSubjectMmsi;
|
visible && record.id !== state.cockpitSubjectId;
|
||||||
if (visible && doRotations && scene) {
|
if (visible && doRotations && scene) {
|
||||||
const rot = screenProjectedRotation(
|
const rot = screenProjectedRotation(
|
||||||
scene,
|
scene,
|
||||||
@@ -445,8 +445,7 @@ export function createRendering({
|
|||||||
const selected = state.selectedRecord;
|
const selected = state.selectedRecord;
|
||||||
// From the bridge, the selected vessel is the viewer; its card would be
|
// From the bridge, the selected vessel is the viewer; its card would be
|
||||||
// pinned to the camera position.
|
// pinned to the camera position.
|
||||||
const selectedCarded =
|
const selectedCarded = selected && selected.id !== state.cockpitSubjectId;
|
||||||
selected && selected.mmsi !== state.cockpitSubjectMmsi;
|
|
||||||
const entries = selectedCarded
|
const entries = selectedCarded
|
||||||
? [components.cards.buildSelectedVesselCard(selected)]
|
? [components.cards.buildSelectedVesselCard(selected)]
|
||||||
: [];
|
: [];
|
||||||
@@ -532,17 +531,17 @@ export function createRendering({
|
|||||||
VESSEL_CARD_FADE_DISTANCE_M,
|
VESSEL_CARD_FADE_DISTANCE_M,
|
||||||
);
|
);
|
||||||
if (!card.interactive) return card;
|
if (!card.interactive) return card;
|
||||||
const mmsi = String(card.id || '').startsWith('vessel:')
|
const id = String(card.id || '').startsWith('vessel:')
|
||||||
? card.id.slice('vessel:'.length)
|
? card.id.slice('vessel:'.length)
|
||||||
: '';
|
: '';
|
||||||
|
const mmsi = state.records.byId.get(id)?.mmsi || '';
|
||||||
return {
|
return {
|
||||||
...card,
|
...card,
|
||||||
accessibilityLabel: t('Focus vessel {name}, MMSI {mmsi}', {
|
accessibilityLabel: mmsi
|
||||||
name: card.title,
|
? t('Focus vessel {name}, MMSI {mmsi}', { name: card.title, mmsi })
|
||||||
mmsi,
|
: t('Focus {label}', { label: card.title }),
|
||||||
}),
|
|
||||||
activate: () => {
|
activate: () => {
|
||||||
const record = state.records.byMmsi.get(mmsi);
|
const record = state.records.byId.get(id);
|
||||||
if (!record) return false;
|
if (!record) return false;
|
||||||
components.selection.selectAndFocusVessel(record);
|
components.selection.selectAndFocusVessel(record);
|
||||||
return true;
|
return true;
|
||||||
@@ -574,7 +573,8 @@ export function createRendering({
|
|||||||
text &&
|
text &&
|
||||||
text !== 'VESSEL' &&
|
text !== 'VESSEL' &&
|
||||||
!/^MMSI\s*\d+$/i.test(text) &&
|
!/^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;
|
if (!state.feed.enabled) return;
|
||||||
// The bridge view owns the selection: a click on open water must not
|
// The bridge view owns the selection: a click on open water must not
|
||||||
// deselect the vessel the camera is riding.
|
// deselect the vessel the camera is riding.
|
||||||
if (state.cockpitSubjectMmsi) return;
|
if (state.cockpitSubjectId) return;
|
||||||
const picked = pickContact
|
const picked = pickContact
|
||||||
? pickContact(viewer.scene, click.position)
|
? pickContact(viewer.scene, click.position)
|
||||||
: viewer.scene.pick(click.position);
|
: viewer.scene.pick(click.position);
|
||||||
const pickedId = resolvePickId(picked);
|
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;
|
const rawId = picked?.id ?? picked?.primitive?.id;
|
||||||
|
// Every vessel record carries an `mmsi` field ('' when unknown); its
|
||||||
|
// key is `id`.
|
||||||
const ownRecordPick =
|
const ownRecordPick =
|
||||||
rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi');
|
rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi');
|
||||||
|
|
||||||
@@ -85,10 +87,10 @@ export function createSelection({
|
|||||||
if (cycloneBackground && cardHit?.sourceId !== VESSEL_OVERLAY_SOURCE_ID)
|
if (cycloneBackground && cardHit?.sourceId !== VESSEL_OVERLAY_SOURCE_ID)
|
||||||
return;
|
return;
|
||||||
if (!record && cardHit) {
|
if (!record && cardHit) {
|
||||||
const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
|
const id = String(cardHit.entryId || '').startsWith('vessel:')
|
||||||
? cardHit.entryId.slice('vessel:'.length)
|
? cardHit.entryId.slice('vessel:'.length)
|
||||||
: null;
|
: 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
|
// A stale card id is not empty terrain and must not clear a newer
|
||||||
// selection. The next paint will evict its hit rectangle.
|
// selection. The next paint will evict its hit rectangle.
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
@@ -100,8 +102,8 @@ export function createSelection({
|
|||||||
selectAndFocusVessel(record);
|
selectAndFocusVessel(record);
|
||||||
} else {
|
} else {
|
||||||
const transition = components.queries.reduceVesselSelection({
|
const transition = components.queries.reduceVesselSelection({
|
||||||
selectedMmsi: state.selectedRecord?.mmsi,
|
selectedId: state.selectedRecord?.id,
|
||||||
pickedMmsi: null,
|
pickedId: null,
|
||||||
gesture: 'click',
|
gesture: 'click',
|
||||||
});
|
});
|
||||||
if (transition.action === 'deselect') clearVesselInspection();
|
if (transition.action === 'deselect') clearVesselInspection();
|
||||||
@@ -123,17 +125,17 @@ export function createSelection({
|
|||||||
/** Select one live vessel and request one UI-owned camera transfer. */
|
/** Select one live vessel and request one UI-owned camera transfer. */
|
||||||
|
|
||||||
function selectAndFocusVessel(record) {
|
function selectAndFocusVessel(record) {
|
||||||
if (!record?.mmsi) return false;
|
if (!record?.id) return false;
|
||||||
const transition = components.queries.reduceVesselSelection({
|
const transition = components.queries.reduceVesselSelection({
|
||||||
selectedMmsi: state.selectedRecord?.mmsi,
|
selectedId: state.selectedRecord?.id,
|
||||||
pickedMmsi: record.mmsi,
|
pickedId: record.id,
|
||||||
gesture: 'click',
|
gesture: 'click',
|
||||||
});
|
});
|
||||||
if (transition.action === 'select') selectVessel(record);
|
if (transition.action === 'select') selectVessel(record);
|
||||||
requestWorldFocus({
|
requestWorldFocus({
|
||||||
kind: 'vessel',
|
kind: 'vessel',
|
||||||
id: record.mmsi,
|
id: record.id,
|
||||||
label: record.name || record.mmsi,
|
label: record.name || record.mmsi || record.id,
|
||||||
position:
|
position:
|
||||||
components.rendering.getVisual(record).billboard?.position ||
|
components.rendering.getVisual(record).billboard?.position ||
|
||||||
components.rendering.getVisual(record).position,
|
components.rendering.getVisual(record).position,
|
||||||
@@ -160,7 +162,7 @@ export function createSelection({
|
|||||||
function onVesselKeyDown(event) {
|
function onVesselKeyDown(event) {
|
||||||
if (!state.feed.enabled || event.key !== 'Escape') return;
|
if (!state.feed.enabled || event.key !== 'Escape') return;
|
||||||
const transition = components.queries.reduceVesselSelection({
|
const transition = components.queries.reduceVesselSelection({
|
||||||
selectedMmsi: state.selectedRecord?.mmsi,
|
selectedId: state.selectedRecord?.id,
|
||||||
gesture: 'escape',
|
gesture: 'escape',
|
||||||
});
|
});
|
||||||
if (transition.action === 'deselect') {
|
if (transition.action === 'deselect') {
|
||||||
@@ -169,8 +171,8 @@ export function createSelection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectVessel(record) {
|
function selectVessel(record) {
|
||||||
if (!record?.mmsi) return;
|
if (!record?.id) return;
|
||||||
const reuseTrail = state.trailMmsi === record.mmsi;
|
const reuseTrail = state.trailId === record.id;
|
||||||
clearSelection({ preserveTrail: reuseTrail });
|
clearSelection({ preserveTrail: reuseTrail });
|
||||||
state.selectedRecord = record;
|
state.selectedRecord = record;
|
||||||
record.missedRefreshes = 0;
|
record.missedRefreshes = 0;
|
||||||
@@ -188,7 +190,7 @@ export function createSelection({
|
|||||||
selectEntityContext(record);
|
selectEntityContext(record);
|
||||||
}
|
}
|
||||||
// Track-history trail (PRD F3/F4): seed with the current position + async
|
// 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) {
|
if (reuseTrail) {
|
||||||
components.tracking.appendSelectedVesselTrailFix(record);
|
components.tracking.appendSelectedVesselTrailFix(record);
|
||||||
} else {
|
} else {
|
||||||
@@ -204,10 +206,10 @@ export function createSelection({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function registerSelectedContext(record) {
|
function registerSelectedContext(record) {
|
||||||
if (!record?.mmsi) return null;
|
if (!record?.id) return null;
|
||||||
try {
|
try {
|
||||||
return registerEntityContext(record, {
|
return registerEntityContext(record, {
|
||||||
id: `ais-${record.mmsi}`,
|
id: `ais-${record.id}`,
|
||||||
layerId: 'ais-live-vessels',
|
layerId: 'ais-live-vessels',
|
||||||
layerName: 'Live AIS Vessels',
|
layerName: 'Live AIS Vessels',
|
||||||
source: aisLiveVesselsLayer.source,
|
source: aisLiveVesselsLayer.source,
|
||||||
@@ -215,7 +217,8 @@ export function createSelection({
|
|||||||
latitude: record.lat,
|
latitude: record.lat,
|
||||||
longitude: record.lon,
|
longitude: record.lon,
|
||||||
properties: {
|
properties: {
|
||||||
mmsi: record.mmsi,
|
vesselId: record.id,
|
||||||
|
mmsi: record.mmsi || null,
|
||||||
type: record.type,
|
type: record.type,
|
||||||
speedKt: record.speed,
|
speedKt: record.speed,
|
||||||
course: record.course,
|
course: record.course,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function createVesselSnapshotRenderer({
|
|||||||
const nextIcon = rendering.shipIcon(record, selected);
|
const nextIcon = rendering.shipIcon(record, selected);
|
||||||
if (nextIcon !== prevIcon) visual.billboard.image = nextIcon;
|
if (nextIcon !== prevIcon) visual.billboard.image = nextIcon;
|
||||||
}
|
}
|
||||||
if (record.mmsi === state.trailMmsi)
|
if (record.id === state.trailId)
|
||||||
tracking.appendSelectedVesselTrailFix(record);
|
tracking.appendSelectedVesselTrailFix(record);
|
||||||
if (selected) {
|
if (selected) {
|
||||||
cards.updateSelectedVesselHud(record);
|
cards.updateSelectedVesselHud(record);
|
||||||
@@ -55,8 +55,8 @@ export function createVesselSnapshotRenderer({
|
|||||||
if (evicted) selection.clearVesselInspection({ evicted: true });
|
if (evicted) selection.clearVesselInspection({ evicted: true });
|
||||||
rendering.removeRecordPrimitives(record);
|
rendering.removeRecordPrimitives(record);
|
||||||
},
|
},
|
||||||
removed(mmsi) {
|
removed(id) {
|
||||||
if (state.trailMmsi === mmsi) tracking.clearSelectedVesselTrail();
|
if (state.trailId === id) tracking.clearSelectedVesselTrail();
|
||||||
},
|
},
|
||||||
staleSelected(record) {
|
staleSelected(record) {
|
||||||
cards.updateSelectedVesselHud(record);
|
cards.updateSelectedVesselHud(record);
|
||||||
@@ -64,7 +64,7 @@ export function createVesselSnapshotRenderer({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
// The selected vessel at the new time: its trail starts there.
|
// 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);
|
tracking.startSelectedVesselTrail(state.selectedRecord);
|
||||||
state.lastVisibilityUpdate = 0;
|
state.lastVisibilityUpdate = 0;
|
||||||
rendering.updateVisibility(true);
|
rendering.updateVisibility(true);
|
||||||
|
|||||||
@@ -86,14 +86,14 @@ export function createVesselState({ source, services }) {
|
|||||||
activeFocusCount: 0,
|
activeFocusCount: 0,
|
||||||
activeLabelCount: 0,
|
activeLabelCount: 0,
|
||||||
selectedRecord: null,
|
selectedRecord: null,
|
||||||
/** @type {string|null} MMSI whose bridge the Cockpit camera occupies; its sprite and card stay hidden. */
|
/** @type {string|null} Id of the vessel whose bridge the Cockpit camera occupies; its sprite and card stay hidden. */
|
||||||
cockpitSubjectMmsi: null,
|
cockpitSubjectId: null,
|
||||||
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
|
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
|
||||||
trail: null,
|
trail: null,
|
||||||
/** @type {Cesium.Cartesian3[]} Chronological trail vertices (oldest first) */
|
/** @type {Cesium.Cartesian3[]} Chronological trail vertices (oldest first) */
|
||||||
trailPositions: [],
|
trailPositions: [],
|
||||||
/** @type {string|null} MMSI that owns the active selected-vessel trail. */
|
/** @type {string|null} Id of the vessel that owns the active selected-vessel trail. */
|
||||||
trailMmsi: null,
|
trailId: null,
|
||||||
/** @type {number} Monotonic token — invalidates in-flight backfill responses */
|
/** @type {number} Monotonic token — invalidates in-flight backfill responses */
|
||||||
trailBackfillToken: 0,
|
trailBackfillToken: 0,
|
||||||
trailAbort: null,
|
trailAbort: null,
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export function createTesting({
|
|||||||
function _setVesselStateForTest(options = {}) {
|
function _setVesselStateForTest(options = {}) {
|
||||||
components.lifecycle.resetState();
|
components.lifecycle.resetState();
|
||||||
const records = Array.isArray(options.records) ? options.records : [];
|
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.viewer = options.viewer || null;
|
||||||
state.feed.enabled = options.enabled !== false;
|
state.feed.enabled = options.enabled !== false;
|
||||||
state.feed.loaded = options.loaded === true;
|
state.feed.loaded = options.loaded === true;
|
||||||
@@ -42,15 +47,15 @@ export function createTesting({
|
|||||||
state.feed.lastUpdate = options.lastUpdate ?? null;
|
state.feed.lastUpdate = options.lastUpdate ?? null;
|
||||||
state.records.all = records;
|
state.records.all = records;
|
||||||
state.feed.count = records.length;
|
state.feed.count = records.length;
|
||||||
state.records.byMmsi = new Map(
|
state.records.byId = new Map(
|
||||||
records
|
records
|
||||||
.filter((record) => record?.mmsi)
|
.filter((record) => record?.id)
|
||||||
.map((record) => [record.mmsi, record]),
|
.map((record) => [record.id, record]),
|
||||||
);
|
);
|
||||||
state.selectedRecord = options.selectedRecord || null;
|
state.selectedRecord = options.selectedRecord || null;
|
||||||
state.billboardCollection = options.billboardCollection || { remove() {} };
|
state.billboardCollection = options.billboardCollection || { remove() {} };
|
||||||
state.trail = options.trail || null;
|
state.trail = options.trail || null;
|
||||||
state.trailMmsi = options.trailMmsi || null;
|
state.trailId = options.trailId || null;
|
||||||
state.trailPositions = Array.isArray(options.trailPositions)
|
state.trailPositions = Array.isArray(options.trailPositions)
|
||||||
? [...options.trailPositions]
|
? [...options.trailPositions]
|
||||||
: [];
|
: [];
|
||||||
@@ -141,8 +146,8 @@ export function createTesting({
|
|||||||
lastMessageAt: state.feed.lastMessageAt,
|
lastMessageAt: state.feed.lastMessageAt,
|
||||||
rawRowCount: state.feed.rawRowCount,
|
rawRowCount: state.feed.rawRowCount,
|
||||||
acceptedRowCount: state.feed.acceptedRowCount,
|
acceptedRowCount: state.feed.acceptedRowCount,
|
||||||
selectedMmsi: state.selectedRecord?.mmsi || null,
|
selectedId: state.selectedRecord?.id || null,
|
||||||
trailMmsi: state.trailMmsi,
|
trailId: state.trailId,
|
||||||
trailPositionCount: state.trailPositions.length,
|
trailPositionCount: state.trailPositions.length,
|
||||||
sessionId: state.feed.sessionId,
|
sessionId: state.feed.sessionId,
|
||||||
firstConnectPhase: state.feed.firstConnectPhase,
|
firstConnectPhase: state.feed.firstConnectPhase,
|
||||||
@@ -166,14 +171,14 @@ export function createTesting({
|
|||||||
/**
|
/**
|
||||||
* Read lifecycle ownership state without exposing the mutable state object.
|
* Read lifecycle ownership state without exposing the mutable state object.
|
||||||
* Test-only seam.
|
* Test-only seam.
|
||||||
* @returns {{trailMmsi: string|null, trailPositionCount: number, vesselCount: number}}
|
* @returns {{trailId: string|null, trailPositionCount: number, vesselCount: number}}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function _getVesselStateForTest() {
|
function _getVesselStateForTest() {
|
||||||
return {
|
return {
|
||||||
trailMmsi: state.trailMmsi,
|
trailId: state.trailId,
|
||||||
trailPositionCount: state.trailPositions.length,
|
trailPositionCount: state.trailPositions.length,
|
||||||
vesselCount: state.records.byMmsi.size,
|
vesselCount: state.records.byId.size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function createTracking({
|
|||||||
state.trailAbort?.abort();
|
state.trailAbort?.abort();
|
||||||
state.trailAbort = new AbortController();
|
state.trailAbort = new AbortController();
|
||||||
state.trailBackfillToken += 1;
|
state.trailBackfillToken += 1;
|
||||||
state.trailMmsi = record.mmsi;
|
state.trailId = record.id;
|
||||||
state.trailPositions = [];
|
state.trailPositions = [];
|
||||||
const current = vesselTrailPosition(record);
|
const current = vesselTrailPosition(record);
|
||||||
if (current) state.trailPositions.push(current);
|
if (current) state.trailPositions.push(current);
|
||||||
@@ -97,25 +97,21 @@ export function createTracking({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (state.trail) state.trail.setPositions(state.trailPositions);
|
if (state.trail) state.trail.setPositions(state.trailPositions);
|
||||||
backfillVesselTrail(
|
backfillVesselTrail(record.id, state.trailBackfillToken, record.reference);
|
||||||
record.mmsi,
|
|
||||||
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
|
* (PRD F3 — "recent path" since server boot, not voyage history). Older
|
||||||
* samples are spliced AHEAD of the live accumulation, capped at
|
* samples are spliced AHEAD of the live accumulation, capped at
|
||||||
* TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/malformed)
|
* TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/malformed)
|
||||||
* silently keeps the live-only trail.
|
* 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.
|
* @param {number} token - Backfill token captured at request time.
|
||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
async function backfillVesselTrail(mmsi, token, reference = mmsi) {
|
async function backfillVesselTrail(id, token, reference = id) {
|
||||||
const owner = state.trailAbort;
|
const owner = state.trailAbort;
|
||||||
// Following the world clock, the track ends at the world's time.
|
// Following the world clock, the track ends at the world's time.
|
||||||
const atMs = followsClock(vesselState) ? vesselState.worldNow() : undefined;
|
const atMs = followsClock(vesselState) ? vesselState.worldNow() : undefined;
|
||||||
@@ -139,7 +135,7 @@ export function createTracking({
|
|||||||
token !== state.trailBackfillToken
|
token !== state.trailBackfillToken
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
if (state.trailMmsi !== mmsi) return;
|
if (state.trailId !== id) return;
|
||||||
|
|
||||||
const older = [];
|
const older = [];
|
||||||
for (const sample of samples) {
|
for (const sample of samples) {
|
||||||
@@ -198,7 +194,7 @@ export function createTracking({
|
|||||||
state.trailAbort?.abort();
|
state.trailAbort?.abort();
|
||||||
state.trailAbort = null;
|
state.trailAbort = null;
|
||||||
state.trailBackfillToken += 1;
|
state.trailBackfillToken += 1;
|
||||||
state.trailMmsi = null;
|
state.trailId = null;
|
||||||
state.trailPositions = [];
|
state.trailPositions = [];
|
||||||
if (state.trail) state.trail.clear();
|
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,
|
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 EARTH_RADIUS_M = 6_371_000;
|
||||||
const RAD = Math.PI / 180;
|
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];
|
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`,
|
* 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
|
* 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 {
|
export {
|
||||||
SHARE_VIEW_ENTITY_KINDS,
|
SHARE_VIEW_ENTITY_KINDS,
|
||||||
SHARE_VIEW_FOLLOW_OFFSET_DEFAULT,
|
SHARE_VIEW_FOLLOW_OFFSET_DEFAULT,
|
||||||
|
SHARE_VIEW_FOLLOW_RANGE_MAX_M,
|
||||||
followCameraFor,
|
followCameraFor,
|
||||||
|
followOffsetFor,
|
||||||
followOffsetFromEnu,
|
followOffsetFromEnu,
|
||||||
followOffsetToEnu,
|
followOffsetToEnu,
|
||||||
} from './follow.js';
|
} 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
|
* 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
|
* are the entity ids, one per SHARE_VIEW_ENTITY_KINDS code, and field 13 is a
|
||||||
* present. The follow offset and the last-known fix are flat fields here, not
|
* 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.
|
* sub-messages, to save their tag and length bytes.
|
||||||
*/
|
*/
|
||||||
export const SHARE_VIEW_THING_FIELDS = Object.freeze({
|
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
|
alt: 10, // sint metres, its last-known altitude
|
||||||
ageS: 11, // sint, seconds the fix predates the created-at time (field 8)
|
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
|
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`. */
|
/** The default of a field or flag for a view in `style`. */
|
||||||
function defaultOf(entry, style) {
|
function defaultOf(entry, style) {
|
||||||
if (entry.default !== BY_STYLE) return entry.default;
|
if (entry.default !== BY_STYLE) return entry.default;
|
||||||
@@ -490,7 +502,10 @@ function normalizeEntityId(entry, value) {
|
|||||||
: typeof value === 'string'
|
: typeof value === 'string'
|
||||||
? value.trim().toLowerCase()
|
? value.trim().toLowerCase()
|
||||||
: null;
|
: 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;
|
if (entry.kind === 'satellite' && !/^[1-9]\d{0,14}$/.test(raw)) return null;
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
@@ -795,6 +810,31 @@ const REGISTRY_BY_TOKEN = new Map(
|
|||||||
const charNumber = (char) => LAYER_STATE_TOKEN_ALPHABET.indexOf(char);
|
const charNumber = (char) => LAYER_STATE_TOKEN_ALPHABET.indexOf(char);
|
||||||
const CANONICAL_INTEGER = /^(0|-?[1-9]\d{0,14})$/;
|
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) {
|
function writeEntityId(writer, entry, id) {
|
||||||
const field = entry.code;
|
const field = entry.code;
|
||||||
if (entry.idForm === 'hex24' && /^[0-9a-f]{6}$/.test(id))
|
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 f = SHARE_VIEW_THING_FIELDS;
|
||||||
const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT;
|
const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT;
|
||||||
const thing = new WireWriter();
|
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;
|
const { range, heading, pitch } = view.offset;
|
||||||
if (range !== d.range) thing.uintField(f.range, range);
|
if (range !== d.range) thing.uintField(f.range, range);
|
||||||
if (heading !== d.heading) thing.sintField(f.heading, signedAngle(heading));
|
if (heading !== d.heading) thing.sintField(f.heading, signedAngle(heading));
|
||||||
@@ -914,6 +955,8 @@ function encodeThing(writer, view, state) {
|
|||||||
} else if (observed !== null)
|
} else if (observed !== null)
|
||||||
thing.sintField(f.observedAt, epochSeconds(observed));
|
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());
|
writer.bytesField(fieldNumber('thing'), thing.finish());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1063,7 +1106,9 @@ const TOP_LEVEL_WIRE_TYPES = new Map(
|
|||||||
const THING_WIRE_TYPES = new Map([
|
const THING_WIRE_TYPES = new Map([
|
||||||
...SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, [WIRE_VARINT, WIRE_LEN]]),
|
...SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, [WIRE_VARINT, WIRE_LEN]]),
|
||||||
...Object.values(SHARE_VIEW_THING_FIELDS).map((n) => [n, [WIRE_VARINT]]),
|
...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 unz = unzigzag;
|
||||||
const CLOSED_ENUMS = new Set(['detectionMode', 'detectionAllocation']);
|
const CLOSED_ENUMS = new Set(['detectionMode', 'detectionAllocation']);
|
||||||
@@ -1258,8 +1303,18 @@ function decodeThing(reader, createdAtS, unknown) {
|
|||||||
? followCameraFor(lastKnown, normalizeFollowOffset(offset))
|
? followCameraFor(lastKnown, normalizeFollowOffset(offset))
|
||||||
: null;
|
: null;
|
||||||
const present = SHARE_VIEW_ENTITY_KINDS.filter((e) => fields.has(e.code));
|
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');
|
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];
|
const entry = present[0];
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
// A newer app's entity kind uses a thing field this decoder skipped.
|
// A newer app's entity kind uses a thing field this decoder skipped.
|
||||||
|
|||||||
@@ -43,14 +43,17 @@ import {
|
|||||||
BY_STYLE,
|
BY_STYLE,
|
||||||
SHARE_VIEW_FLAGS,
|
SHARE_VIEW_FLAGS,
|
||||||
SHARE_VIEW_FOLLOW_OFFSET_DEFAULT,
|
SHARE_VIEW_FOLLOW_OFFSET_DEFAULT,
|
||||||
|
SHARE_VIEW_FOLLOW_RANGE_MAX_M,
|
||||||
SHARE_VIEW_STYLE_DEFAULTS,
|
SHARE_VIEW_STYLE_DEFAULTS,
|
||||||
SHARE_VIEW_LAYER_OPTION_ABSENT,
|
SHARE_VIEW_LAYER_OPTION_ABSENT,
|
||||||
SHARE_VIEW_PANELS,
|
SHARE_VIEW_PANELS,
|
||||||
SHARE_VIEW_STYLE_PARAMS,
|
SHARE_VIEW_STYLE_PARAMS,
|
||||||
|
SHARE_VIEW_THING_FIELDS,
|
||||||
createDefaultShareViewState,
|
createDefaultShareViewState,
|
||||||
decodeShareView,
|
decodeShareView,
|
||||||
encodeShareView,
|
encodeShareView,
|
||||||
followCameraFor,
|
followCameraFor,
|
||||||
|
followOffsetFor,
|
||||||
followOffsetFromEnu,
|
followOffsetFromEnu,
|
||||||
followOffsetToEnu,
|
followOffsetToEnu,
|
||||||
layerTokenFromNumber,
|
layerTokenFromNumber,
|
||||||
@@ -149,6 +152,7 @@ function randomState(random) {
|
|||||||
String(int(100000000, 999999999)),
|
String(int(100000000, 999999999)),
|
||||||
'002442000',
|
'002442000',
|
||||||
'tmp-7',
|
'tmp-7',
|
||||||
|
'0770e3ca-b896-c307-f499-4f9df2b8f86b',
|
||||||
])
|
])
|
||||||
: pick(random, [
|
: pick(random, [
|
||||||
int(0, 0xffffff).toString(16).padStart(6, '0'),
|
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,
|
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.
|
* getTrack(reference, { signal }). References are opaque to consumers.
|
||||||
* Snapshots describe coverage and completeness independently of freshness.
|
* Snapshots describe coverage and completeness independently of freshness.
|
||||||
* Records contain observation data only, with no scene objects or transport data.
|
* 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
|
* 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.
|
* 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
|
* malformed: `{ coverage(), stepMs, subscribe? }`. `coverage()` returns the
|
||||||
* span `{start, end}` (epoch ms) the source can answer a snapshot for, or
|
* 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;
|
* 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
|
* the optional `subscribe(listener)` tells of a change in coverage, or of a
|
||||||
* returns the unsubscribe.
|
* 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
|
* @param {object|null|undefined} source
|
||||||
* @returns {{coverage: () => ({start: number, end: number}|null),
|
* @returns {{coverage: () => ({start: number, end: number}|null),
|
||||||
* stepMs: number, subscribe: ((listener: () => void) => () => void)|null}|null}
|
* stepMs: number, subscribe: ((listener: () => void) => () => void)|null}|null}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
openSkySnapshot,
|
openSkySnapshot,
|
||||||
readsbSnapshot,
|
readsbSnapshot,
|
||||||
readSnapshotTimeline,
|
readSnapshotTimeline,
|
||||||
|
vesselSnapshot,
|
||||||
} from './index.js';
|
} from './index.js';
|
||||||
|
|
||||||
const now = 1800000000000;
|
const now = 1800000000000;
|
||||||
@@ -196,6 +197,7 @@ test('vessel identity, opaque reference and heading/course remain distinct, unkn
|
|||||||
'opaque-reference',
|
'opaque-reference',
|
||||||
);
|
);
|
||||||
assert.equal(row.id, '123456789');
|
assert.equal(row.id, '123456789');
|
||||||
|
assert.equal(row.mmsi, '123456789');
|
||||||
assert.equal(row.reference, 'opaque-reference');
|
assert.equal(row.reference, 'opaque-reference');
|
||||||
assert.equal(row.headingDeg, 45);
|
assert.equal(row.headingDeg, 45);
|
||||||
assert.equal(row.courseDeg, 60);
|
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();
|
stop();
|
||||||
assert.equal(listeners.length, 0);
|
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';
|
import { coordinates, epoch, finite, LiveSourceError } from './contract.js';
|
||||||
|
|
||||||
/** AIS positions are sea-surface observations; heading and course are distinct. */
|
/** An MMSI is up to ten digits (AIS carries it in 30 bits); anything else a
|
||||||
export function normalizeVesselObservation(row, reference = null) {
|
* row names it is not one. */
|
||||||
const id = String(row?.mmsi || row?.input_identifier || '').trim();
|
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),
|
const latitude = finite(row?.lat),
|
||||||
longitude = finite(row?.lon);
|
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);
|
const speedKts = finite(row.speed);
|
||||||
return {
|
return {
|
||||||
id,
|
id: key,
|
||||||
reference: reference ?? id,
|
mmsi,
|
||||||
|
reference: reference ?? key,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
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 || ''),
|
imo: String(row.imo || ''),
|
||||||
type: String(row.type_specific || row.type || ''),
|
type: String(row.type_specific || row.type || ''),
|
||||||
destination: String(row.destination || ''),
|
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(
|
export function vesselSnapshot(
|
||||||
payload,
|
payload,
|
||||||
{
|
{
|
||||||
source = 'AISStream',
|
source = 'AISStream',
|
||||||
coverage = 'received AIS positions',
|
coverage = 'received AIS positions',
|
||||||
referenceFor = (row) => String(row.mmsi || row.input_identifier || ''),
|
idFor = defaultVesselId,
|
||||||
|
referenceFor = null,
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
if (!Array.isArray(payload?.rows))
|
if (!Array.isArray(payload?.rows))
|
||||||
@@ -40,8 +72,13 @@ export function vesselSnapshot(
|
|||||||
const records = [],
|
const records = [],
|
||||||
ids = new Set();
|
ids = new Set();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const record = normalizeVesselObservation(row);
|
const record = normalizeVesselObservation(
|
||||||
if (record) record.reference = referenceFor(row);
|
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)) {
|
if (record && !ids.has(record.id)) {
|
||||||
records.push(record);
|
records.push(record);
|
||||||
ids.add(record.id);
|
ids.add(record.id);
|
||||||
|
|||||||
@@ -279,6 +279,13 @@ export function createSnapshotTimeline({
|
|||||||
} else keepUp(timeMs, state);
|
} else keepUp(timeMs, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Load the time shown afresh. */
|
||||||
|
function reloadShown() {
|
||||||
|
if (!started || clock.isLive()) return;
|
||||||
|
failedUntil = -Infinity;
|
||||||
|
jumpTo(clock.getState().timeMs);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** The source's timeline while the layer follows the clock, else null. */
|
/** The source's timeline while the layer follows the clock, else null. */
|
||||||
get timeline() {
|
get timeline() {
|
||||||
@@ -304,16 +311,17 @@ export function createSnapshotTimeline({
|
|||||||
clock.register({ id, coverage: () => span() }),
|
clock.register({ id, coverage: () => span() }),
|
||||||
clock.subscribe(follow),
|
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);
|
if (typeof unsubscribe === 'function') releases.push(unsubscribe);
|
||||||
follow(clock.getState());
|
follow(clock.getState());
|
||||||
},
|
},
|
||||||
/** Load the time shown afresh, as when the area the layer asks for moved. */
|
/** Load the time shown afresh, as when the area the layer asks for moved. */
|
||||||
reload() {
|
reload: () => reloadShown(),
|
||||||
if (!started || clock.isLive()) return;
|
|
||||||
failedUntil = -Infinity;
|
|
||||||
jumpTo(clock.getState().timeMs);
|
|
||||||
},
|
|
||||||
/** The layer is off: stop following and drop what loads. */
|
/** The layer is off: stop following and drop what loads. */
|
||||||
stop() {
|
stop() {
|
||||||
if (!started) return;
|
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();
|
h.follower.stop();
|
||||||
assert.equal(live(), 0);
|
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}$',
|
pattern: '^\\d{5,10}$',
|
||||||
description: 'Maritime Mobile Service Identity, such as "366999712".',
|
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 MAX_TRACK_POINTS = 200;
|
||||||
const ALL_VESSELS = 1_000_000;
|
const ALL_VESSELS = 1_000_000;
|
||||||
|
|
||||||
@@ -47,10 +55,13 @@ const vesselDestination = (value) => {
|
|||||||
return destination && /\p{L}/u.test(destination) ? destination : null;
|
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) {
|
function vesselRow(record, from) {
|
||||||
const point = { lat: record.latitude, lon: record.longitude };
|
const point = { lat: record.latitude, lon: record.longitude };
|
||||||
return {
|
return {
|
||||||
mmsi: record.id,
|
id: record.id,
|
||||||
|
mmsi: text(record.mmsi),
|
||||||
name: text(record.name),
|
name: text(record.name),
|
||||||
imo: text(record.imo),
|
imo: text(record.imo),
|
||||||
type: vesselType(record.type),
|
type: vesselType(record.type),
|
||||||
@@ -194,7 +205,7 @@ export const findVessel = defineTool({
|
|||||||
const [key] = given;
|
const [key] = given;
|
||||||
const wanted = args[key].trim().toUpperCase();
|
const wanted = args[key].trim().toUpperCase();
|
||||||
const matches = {
|
const matches = {
|
||||||
mmsi: (record) => record.id === wanted,
|
mmsi: (record) => String(record.mmsi ?? '') === wanted,
|
||||||
imo: (record) => String(record.imo).replace(/^IMO/i, '') === wanted,
|
imo: (record) => String(record.imo).replace(/^IMO/i, '') === wanted,
|
||||||
name: (record) => String(record.name).toUpperCase().includes(wanted),
|
name: (record) => String(record.name).toUpperCase().includes(wanted),
|
||||||
}[key];
|
}[key];
|
||||||
@@ -227,25 +238,40 @@ export const getVesselTrack = defineTool({
|
|||||||
name: 'get_vessel_track',
|
name: 'get_vessel_track',
|
||||||
title: 'Vessel track',
|
title: 'Vessel track',
|
||||||
description:
|
description:
|
||||||
'Recent positions of one ship by MMSI, oldest first. Long tracks are ' +
|
'Recent positions of one ship by its vessel id or MMSI, oldest first. ' +
|
||||||
`evenly thinned to at most ${MAX_TRACK_POINTS} points.`,
|
`Long tracks are evenly thinned to at most ${MAX_TRACK_POINTS} points.`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { mmsi: MMSI_SCHEMA },
|
properties: { id: VESSEL_ID_SCHEMA, mmsi: MMSI_SCHEMA },
|
||||||
required: ['mmsi'],
|
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
requires: ['vessels'],
|
requires: ['vessels'],
|
||||||
async run(args, { services, signal }) {
|
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
|
const points = track.records
|
||||||
.filter((point) => Number.isFinite(point.observedAtMs))
|
.filter((point) => Number.isFinite(point.observedAtMs))
|
||||||
.sort((a, b) => a.observedAtMs - b.observedAtMs);
|
.sort((a, b) => a.observedAtMs - b.observedAtMs);
|
||||||
const kept = thinEvenly(points, MAX_TRACK_POINTS);
|
const kept = thinEvenly(points, MAX_TRACK_POINTS);
|
||||||
return {
|
return {
|
||||||
summary: points.length
|
summary: points.length
|
||||||
? `${countNoun(points.length, 'position')} for ${args.mmsi} from ${isoTime(points[0].observedAtMs)} to ${isoTime(points.at(-1).observedAtMs)}.`
|
? `${countNoun(points.length, 'position')} for ${wanted} from ${isoTime(points[0].observedAtMs)} to ${isoTime(points.at(-1).observedAtMs)}.`
|
||||||
: `No recent track is available for ${args.mmsi}.`,
|
: `No recent track is available for ${wanted}.`,
|
||||||
data: {
|
data: {
|
||||||
view: kept.length
|
view: kept.length
|
||||||
? suggestView(services, {
|
? suggestView(services, {
|
||||||
@@ -254,7 +280,7 @@ export const getVesselTrack = defineTool({
|
|||||||
altitudeM: 20_000,
|
altitudeM: 20_000,
|
||||||
})
|
})
|
||||||
: null,
|
: null,
|
||||||
mmsi: args.mmsi,
|
...(key === 'id' ? { id: wanted } : { mmsi: wanted }),
|
||||||
total: points.length,
|
total: points.length,
|
||||||
returned: kept.length,
|
returned: kept.length,
|
||||||
thinned: kept.length < points.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 { createVesselSource } from '../../sources/live/standalone.js';
|
||||||
import { AISSTREAM_CACHE_MAX } from '../../../server/providers/vessels/ais-store.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 = {}) => ({
|
const vessel = (id, latitude, longitude, extra = {}) => ({
|
||||||
id,
|
id,
|
||||||
|
mmsi: id,
|
||||||
reference: id,
|
reference: id,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
@@ -79,6 +81,7 @@ test('vessels in an area are nearest first and filterable by type', async () =>
|
|||||||
['366999712', '538001234'],
|
['366999712', '538001234'],
|
||||||
);
|
);
|
||||||
assert.deepEqual(all.data.rows[0], {
|
assert.deepEqual(all.data.rows[0], {
|
||||||
|
id: '366999712',
|
||||||
mmsi: '366999712',
|
mmsi: '366999712',
|
||||||
name: 'GOLDEN BEAR',
|
name: 'GOLDEN BEAR',
|
||||||
imo: '9123456',
|
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 () => {
|
test('a missing AIS key surfaces as an unavailable feed', async () => {
|
||||||
const keyless = {
|
const keyless = {
|
||||||
getSnapshot: async () => {
|
getSnapshot: async () => {
|
||||||
|
|||||||
@@ -472,7 +472,12 @@ export class StyleManager extends ShellFacade {
|
|||||||
initialShareCode,
|
initialShareCode,
|
||||||
});
|
});
|
||||||
this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) =>
|
this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) =>
|
||||||
readShareFollowSubject({ viewer: this.viewer, nowMs }),
|
readShareFollowSubject({
|
||||||
|
viewer: this.viewer,
|
||||||
|
nowMs,
|
||||||
|
readSelectedVessel: () =>
|
||||||
|
this.services.aisLiveVesselsLayer?.getSelectedFix?.() ?? null,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
this.shareLinkManager.setPanelStateProvider(() =>
|
this.shareLinkManager.setPanelStateProvider(() =>
|
||||||
this._buildSharePanelState(),
|
this._buildSharePanelState(),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js';
|
|||||||
import { VesselRecords } from '../layers/vessels/records.js';
|
import { VesselRecords } from '../layers/vessels/records.js';
|
||||||
|
|
||||||
const RECORD = Object.freeze({
|
const RECORD = Object.freeze({
|
||||||
|
id: '211000000',
|
||||||
mmsi: '211000000',
|
mmsi: '211000000',
|
||||||
name: 'NORDIC STAR',
|
name: 'NORDIC STAR',
|
||||||
type: '70',
|
type: '70',
|
||||||
@@ -65,9 +66,9 @@ function vesselLayer(record) {
|
|||||||
enabled && selected
|
enabled && selected
|
||||||
? { ...vesselCockpitInfo(selected), seaSurfaceHeightM: 0 }
|
? { ...vesselCockpitInfo(selected), seaSurfaceHeightM: 0 }
|
||||||
: null,
|
: null,
|
||||||
setCockpitSubject(mmsi) {
|
setCockpitSubject(id) {
|
||||||
calls.push(['subject', mmsi]);
|
calls.push(['subject', id]);
|
||||||
subject = mmsi;
|
subject = id;
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
// Like the layer, leaves out the ship whose bridge the Cockpit rides.
|
// 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 });
|
radarQueries.push({ lat, lon, rangeM, maxCount });
|
||||||
const own = {
|
const own = {
|
||||||
...NEIGHBOUR,
|
...NEIGHBOUR,
|
||||||
id: record.mmsi,
|
id: record.id,
|
||||||
latitude: record.lat,
|
latitude: record.lat,
|
||||||
longitude: record.lon,
|
longitude: record.lon,
|
||||||
};
|
};
|
||||||
@@ -257,7 +258,7 @@ test('entering the bridge hides the vessel and shows bridge readouts', async (t)
|
|||||||
await settle();
|
await settle();
|
||||||
assert.equal(cockpit.active, true);
|
assert.equal(cockpit.active, true);
|
||||||
assert.equal(cockpit.subjectKind, 'vessel');
|
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(document.body.classList.contains('cockpit-mode'), true);
|
||||||
|
|
||||||
assert.equal(
|
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.active, false);
|
||||||
assert.equal(cockpit.subjectKind, null);
|
assert.equal(cockpit.subjectKind, null);
|
||||||
assert.deepEqual(vessels.calls, [
|
assert.deepEqual(vessels.calls, [
|
||||||
['subject', RECORD.mmsi],
|
['subject', RECORD.id],
|
||||||
['subject', null],
|
['subject', null],
|
||||||
['focus'],
|
['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(viewer.trackedEntity, aircraft, 'the aircraft keeps tracking');
|
||||||
assert.equal(aircraft.show, true, 'the aircraft was never adopted');
|
assert.equal(aircraft.show, true, 'the aircraft was never adopted');
|
||||||
assert.deepEqual(vessels.calls, [
|
assert.deepEqual(vessels.calls, [
|
||||||
['subject', RECORD.mmsi],
|
['subject', RECORD.id],
|
||||||
['subject', null],
|
['subject', null],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -1474,7 +1475,7 @@ test('a fix the feed keeps re-serving goes on ageing into a stale feed', async (
|
|||||||
effects,
|
effects,
|
||||||
);
|
);
|
||||||
poll();
|
poll();
|
||||||
const record = records.byMmsi.get(RECORD.mmsi);
|
const record = records.byId.get(RECORD.id);
|
||||||
const { $, cockpit, shots } = fixture(t, { record });
|
const { $, cockpit, shots } = fixture(t, { record });
|
||||||
cockpit.enter();
|
cockpit.enter();
|
||||||
await settle();
|
await settle();
|
||||||
@@ -1540,7 +1541,12 @@ test("a ship's double-click memory does not carry over to another ship", async (
|
|||||||
heightSensitiveProjection(viewer);
|
heightSensitiveProjection(viewer);
|
||||||
cockpit.onScenePointerDown(1000);
|
cockpit.onScenePointerDown(1000);
|
||||||
// Within the memory another ship is selected, drawn elsewhere.
|
// 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 });
|
heightSensitiveProjection(viewer, { x: 900 });
|
||||||
assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }, 1250), false);
|
assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }, 1250), false);
|
||||||
assert.equal(cockpit.active, false);
|
assert.equal(cockpit.active, false);
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export function maybeRefreshRegionalBrief(info) {
|
|||||||
!Number.isFinite(info.longitude)
|
!Number.isFinite(info.longitude)
|
||||||
)
|
)
|
||||||
return;
|
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) {
|
if (subjectId !== this.regionalBriefSubjectId) {
|
||||||
this.regionalBriefAbort?.abort();
|
this.regionalBriefAbort?.abort();
|
||||||
this.regionalBriefAbort = null;
|
this.regionalBriefAbort = null;
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ function vesselSurfaceM(info, nowMs, at = info) {
|
|||||||
: 0;
|
: 0;
|
||||||
const scene = this.viewer.scene;
|
const scene = this.viewer.scene;
|
||||||
let read =
|
let read =
|
||||||
this.vesselWaterline?.mmsi === info.mmsi ? this.vesselWaterline : null;
|
this.vesselWaterline?.vesselId === info.id ? this.vesselWaterline : null;
|
||||||
if (
|
if (
|
||||||
typeof scene?.sampleHeightMostDetailed === 'function' &&
|
typeof scene?.sampleHeightMostDetailed === 'function' &&
|
||||||
(!read || nowMs - read.sampledMs >= VESSEL_WATERLINE_REFRESH_MS)
|
(!read || nowMs - read.sampledMs >= VESSEL_WATERLINE_REFRESH_MS)
|
||||||
@@ -138,7 +138,7 @@ function vesselSurfaceM(info, nowMs, at = info) {
|
|||||||
);
|
);
|
||||||
if (!read) {
|
if (!read) {
|
||||||
read = {
|
read = {
|
||||||
mmsi: info.mmsi,
|
vesselId: info.id,
|
||||||
heightM: loadedWaterlineM(scene, probes, seaM),
|
heightM: loadedWaterlineM(scene, probes, seaM),
|
||||||
sampledMs: nowMs,
|
sampledMs: nowMs,
|
||||||
// Whether a full-detail read has answered for this ship.
|
// Whether a full-detail read has answered for this ship.
|
||||||
@@ -219,7 +219,7 @@ function updateVessel(nowMs) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const info = this.readVesselInfo();
|
const info = this.readVesselInfo();
|
||||||
if (!info || info.mmsi !== this.subjectId) {
|
if (!info || info.id !== this.subjectId) {
|
||||||
this.exit({ restoreTracking: false });
|
this.exit({ restoreTracking: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export class CockpitViewController {
|
|||||||
*/
|
*/
|
||||||
this.subjectKind = null;
|
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.
|
* identified by trackedEntity.
|
||||||
*/
|
*/
|
||||||
this.subjectId = null;
|
this.subjectId = null;
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export class CockpitCoordinator {
|
|||||||
: vessel
|
: vessel
|
||||||
? {
|
? {
|
||||||
kind: 'vessel',
|
kind: 'vessel',
|
||||||
id: vessel.mmsi,
|
id: vessel.id,
|
||||||
layerId: vessel.layerId,
|
layerId: vessel.layerId,
|
||||||
callsign: null,
|
callsign: null,
|
||||||
name: vessel.name,
|
name: vessel.name,
|
||||||
|
|||||||
@@ -83,8 +83,10 @@ export function onScenePointerDown(nowMs = performance.now()) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const at = subjectCanvasPosition.call(this);
|
const at = subjectCanvasPosition.call(this);
|
||||||
const mmsi = this.readVesselInfo()?.mmsi ?? null;
|
const vesselId = this.readVesselInfo()?.id ?? null;
|
||||||
this.doubleClickStart = at ? { x: at.x, y: at.y, atMs: nowMs, mmsi } : 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
|
// The memory is the ship's it was taken for: one followed since (an
|
||||||
// aircraft, another ship) is hit only where it is drawn.
|
// aircraft, another ship) is hit only where it is drawn.
|
||||||
this.resolveEntryKind() === 'vessel' &&
|
this.resolveEntryKind() === 'vessel' &&
|
||||||
start.mmsi === (this.readVesselInfo()?.mmsi ?? null) &&
|
start.vesselId === (this.readVesselInfo()?.id ?? null) &&
|
||||||
doubleClickHitsSubject(position, start));
|
doubleClickHitsSubject(position, start));
|
||||||
if (!hit) return false;
|
if (!hit) return false;
|
||||||
this.enter();
|
this.enter();
|
||||||
|
|||||||
@@ -65,7 +65,12 @@ export function resolveEntryKind() {
|
|||||||
|
|
||||||
export function dispatchCockpitModeChanged(active, info = null) {
|
export function dispatchCockpitModeChanged(active, info = null) {
|
||||||
const subjectId = active
|
const subjectId = active
|
||||||
? String(info?.icao24 || info?.mmsi || info?.noradId || '')
|
? String(
|
||||||
|
info?.icao24 ||
|
||||||
|
(info?.kind === 'vessel' ? info.id : '') ||
|
||||||
|
info?.noradId ||
|
||||||
|
'',
|
||||||
|
)
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase() || null
|
.toLowerCase() || null
|
||||||
: null;
|
: null;
|
||||||
@@ -245,7 +250,7 @@ export function enter({ kind = null } = {}) {
|
|||||||
// Each session reads its own water: never a height left from the last.
|
// Each session reads its own water: never a height left from the last.
|
||||||
this.vesselWaterline = null;
|
this.vesselWaterline = null;
|
||||||
this.vesselFix = 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;
|
this.trackedEntity = entity;
|
||||||
if (entity) {
|
if (entity) {
|
||||||
this.trackedEntityWasShown = entity.show;
|
this.trackedEntityWasShown = entity.show;
|
||||||
@@ -255,7 +260,7 @@ export function enter({ kind = null } = {}) {
|
|||||||
this.services.satellitesLayer?.setCockpitSubject?.(info.noradId);
|
this.services.satellitesLayer?.setCockpitSubject?.(info.noradId);
|
||||||
this.beginOrbital();
|
this.beginOrbital();
|
||||||
} else {
|
} 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
|
// A ship reporting neither heading nor course keeps the camera's own
|
||||||
// heading: no direction is made up for it.
|
// 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 { trackedDisplayPositionForCamera } from '../data/trackedCamera.js';
|
||||||
import {
|
import {
|
||||||
SHARE_VIEW_ENTITY_KINDS,
|
SHARE_VIEW_ENTITY_KINDS,
|
||||||
|
SHARE_VIEW_FOLLOW_RANGE_MAX_M,
|
||||||
|
followOffsetFor,
|
||||||
followOffsetFromEnu,
|
followOffsetFromEnu,
|
||||||
followOffsetToEnu,
|
followOffsetToEnu,
|
||||||
} from '../share/follow.js';
|
} from '../share/follow.js';
|
||||||
@@ -28,17 +30,68 @@ function enuOffset(origin, position) {
|
|||||||
return { east: local.x, north: local.y, up: local.z };
|
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 selected vessel as a thing view's subject: its drawn position as the
|
||||||
* the camera's offset from it and its displayed position as of `nowMs`; null
|
* last-known fix, and the follow offset that puts the derived camera
|
||||||
* when the camera follows nothing a share link can name. A selected vessel is
|
* (followCameraFor) where the camera is now. The offset is computed from the
|
||||||
* not followed (the camera stays where the user puts it), so a link made then
|
* fix at the format's precision, so the opened link's camera matches this
|
||||||
* keeps its own camera.
|
* 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;
|
const cameraPosition = viewer?.camera?.positionWC;
|
||||||
if (!cameraPosition) return null;
|
if (!cameraPosition) return null;
|
||||||
const tracked = viewer.trackedEntity;
|
const tracked = viewer.trackedEntity;
|
||||||
|
if (!tracked) {
|
||||||
|
const vessel = readSelectedVessel?.() ?? null;
|
||||||
|
return vessel ? selectedVesselSubject(cameraPosition, vessel, nowMs) : null;
|
||||||
|
}
|
||||||
const trackedId = String(tracked?.gevTrackedId || '');
|
const trackedId = String(tracked?.gevTrackedId || '');
|
||||||
const separator = trackedId.indexOf(':');
|
const separator = trackedId.indexOf(':');
|
||||||
if (separator <= 0) return null;
|
if (separator <= 0) return null;
|
||||||
|
|||||||
+218
-10
@@ -12,6 +12,7 @@ import {
|
|||||||
} from './shareFollow.js';
|
} from './shareFollow.js';
|
||||||
import { createDefaultLayerState } from '../data/layerState.js';
|
import { createDefaultLayerState } from '../data/layerState.js';
|
||||||
import { parseSharePath, shareLinkUrl } from '../share/shareLinkPath.js';
|
import { parseSharePath, shareLinkUrl } from '../share/shareLinkPath.js';
|
||||||
|
import { worldClock } from '../time/worldClock.js';
|
||||||
import {
|
import {
|
||||||
decodeShareView,
|
decodeShareView,
|
||||||
encodeShareView,
|
encodeShareView,
|
||||||
@@ -234,7 +235,9 @@ test('a code that fails its check restores nothing and says so', async (t) => {
|
|||||||
(await page.owner.initialRestorePromise).status,
|
(await page.owner.initialRestorePromise).status,
|
||||||
'not-requested',
|
'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.
|
// The default start's first address write leaves the bad link behind.
|
||||||
page.manager._updateHash();
|
page.manager._updateHash();
|
||||||
assert.equal(page.location.pathname, '/');
|
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();
|
await flush();
|
||||||
assert.equal(page.notices.length, 1);
|
assert.equal(page.notices.length, 1);
|
||||||
assert.match(
|
assert.match(
|
||||||
page.notices[0],
|
page.notices[0].message,
|
||||||
/^A1B2C3 isn't reporting now — showing where it was at .+/,
|
/^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.
|
// A long link names no fix and keeps the existing notice.
|
||||||
page.owner._initialShareState = { shareView: null };
|
page.owner._initialShareState = { shareView: null };
|
||||||
page.owner._handleShareTrackingRestoreStatus({
|
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',
|
label: 'flight',
|
||||||
classification: 'expired',
|
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 at = new Date(2026, 9, 26, 14, 5).getTime();
|
||||||
|
const flight = sharedFixNotice(
|
||||||
|
{ kind: 'aircraft', id: 'a1b2c3' },
|
||||||
|
at,
|
||||||
|
at + 60_000,
|
||||||
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
sharedFixNotice({ kind: 'aircraft', id: 'a1b2c3' }, at, at + 60_000),
|
flight.message,
|
||||||
/^A1B2C3 isn't reporting now — showing where it was at .*05/,
|
/^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(
|
assert.equal(
|
||||||
sharedFixNotice({ kind: 'vessel', id: '366999712' }, null),
|
sharedFixNotice(
|
||||||
"366999712 isn't reporting now — showing where it was last seen",
|
{ 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) => {
|
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();
|
page.runAll();
|
||||||
}
|
}
|
||||||
assert.equal(await missing, false);
|
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) => {
|
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.length, 2);
|
||||||
assert.equal(page.restores[1].style, 'normal');
|
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 });
|
: 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
|
* 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 {{kind: string, id: string}} entity
|
||||||
* @param {number|null} observedAtMs
|
* @param {number|null} observedAtMs
|
||||||
* @param {number} [nowMs]
|
* @param {number} [nowMs]
|
||||||
* @returns {string}
|
* @returns {{message: string, detail: string}}
|
||||||
*/
|
*/
|
||||||
export function sharedFixNotice(entity, observedAtMs, nowMs = Date.now()) {
|
export function sharedFixNotice(entity, observedAtMs, nowMs = Date.now()) {
|
||||||
// ICAO addresses read as they are written on the aircraft: upper case.
|
const detail = sharedFixDetail(entity);
|
||||||
const id =
|
const notices = SHARED_FIX_NOTICES[entity.kind];
|
||||||
ENTITY_BY_KIND.get(entity.kind)?.idForm === 'hex24'
|
if (!notices)
|
||||||
? String(entity.id).toUpperCase()
|
return {
|
||||||
: String(entity.id);
|
message: t('Shared {subject} is unavailable', { subject: t('entity') }),
|
||||||
|
detail,
|
||||||
|
};
|
||||||
if (!Number.isFinite(observedAtMs))
|
if (!Number.isFinite(observedAtMs))
|
||||||
return t("{id} isn't reporting now — showing where it was last seen", {
|
return { message: t(notices.lastSeen), detail };
|
||||||
id,
|
|
||||||
});
|
|
||||||
const time = formatDate(
|
const time = formatDate(
|
||||||
observedAtMs,
|
observedAtMs,
|
||||||
nowMs - observedAtMs < DAY_MS
|
nowMs - observedAtMs < DAY_MS
|
||||||
? { hour: '2-digit', minute: '2-digit' }
|
? { hour: '2-digit', minute: '2-digit' }
|
||||||
: { month: 'short', day: 'numeric', 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}", {
|
return { message: t(notices.at, { time }), detail };
|
||||||
id,
|
|
||||||
time,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Own initial share restoration, durable layer state and restoration notices. */
|
/** Own initial share restoration, durable layer state and restoration notices. */
|
||||||
@@ -181,7 +235,7 @@ export class ShareRestoration {
|
|||||||
this.syncShareState();
|
this.syncShareState();
|
||||||
if (this.shareLinkManager?.rejectedShareCode)
|
if (this.shareLinkManager?.rejectedShareCode)
|
||||||
this._presentAfterStartupCover(
|
this._presentAfterStartupCover(
|
||||||
t("This share link can't be opened"),
|
{ message: t("This share link can't be opened") },
|
||||||
++this._shareTrackingNoticeGeneration,
|
++this._shareTrackingNoticeGeneration,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -411,7 +465,7 @@ export class ShareRestoration {
|
|||||||
this._presentAfterStartupCover(
|
this._presentAfterStartupCover(
|
||||||
view.lastKnown
|
view.lastKnown
|
||||||
? sharedFixNotice(view.entity, view.lastKnown.observedAtMs)
|
? sharedFixNotice(view.entity, view.lastKnown.observedAtMs)
|
||||||
: t('Shared vessel is unavailable'),
|
: { message: t('Shared vessel is unavailable') },
|
||||||
++this._shareTrackingNoticeGeneration,
|
++this._shareTrackingNoticeGeneration,
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
@@ -505,26 +559,36 @@ export class ShareRestoration {
|
|||||||
// A share link that knows where the entity was says so; the view is
|
// A share link that knows where the entity was says so; the view is
|
||||||
// already there.
|
// already there.
|
||||||
const fix = this._sharedThingFor(result)?.lastKnown;
|
const fix = this._sharedThingFor(result)?.lastKnown;
|
||||||
const message = fix
|
const notice = fix
|
||||||
? sharedFixNotice(
|
? sharedFixNotice(
|
||||||
this._initialShareState.shareView.entity,
|
this._initialShareState.shareView.entity,
|
||||||
fix.observedAtMs,
|
fix.observedAtMs,
|
||||||
)
|
)
|
||||||
: shareTrackingRestoreNotice(result);
|
: { message: shareTrackingRestoreNotice(result) };
|
||||||
if (this._resolveInitialShareRestore) {
|
if (this._resolveInitialShareRestore) {
|
||||||
void this.initialRestorePromise.then(() =>
|
void this.initialRestorePromise.then(() =>
|
||||||
this._presentAfterStartupCover(message, noticeGeneration),
|
this._presentAfterStartupCover(notice, noticeGeneration),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._presentAfterStartupCover(message, noticeGeneration);
|
this._presentAfterStartupCover(notice, noticeGeneration);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a status notice once the start-up cover is gone, unless a newer
|
* 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 = () => {
|
const showAfterStartupCover = () => {
|
||||||
this._lifetime.frame(() => {
|
this._lifetime.frame(() => {
|
||||||
if (
|
if (
|
||||||
@@ -540,7 +604,7 @@ export class ShareRestoration {
|
|||||||
!startupCover ||
|
!startupCover ||
|
||||||
getComputedStyle(startupCover).visibility === 'hidden'
|
getComputedStyle(startupCover).visibility === 'hidden'
|
||||||
) {
|
) {
|
||||||
this.showStatus(message);
|
show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let fallbackTimer = null;
|
let fallbackTimer = null;
|
||||||
@@ -555,7 +619,7 @@ export class ShareRestoration {
|
|||||||
this._disposed,
|
this._disposed,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
this.showStatus(message);
|
show();
|
||||||
};
|
};
|
||||||
removeStartupListener = this._lifetime.listen(
|
removeStartupListener = this._lifetime.listen(
|
||||||
startupCover,
|
startupCover,
|
||||||
|
|||||||
@@ -166,6 +166,24 @@ html:not([lang='en']) #global-loading-detail {
|
|||||||
#global-loading-status[data-state='cancelled'] #global-loading-detail {
|
#global-loading-status[data-state='cancelled'] #global-loading-detail {
|
||||||
color: #ffd488;
|
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'] {
|
#global-loading-status[data-state='acquiring'] {
|
||||||
border-color: rgba(0, 212, 255, 0.42);
|
border-color: rgba(0, 212, 255, 0.42);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
|
|||||||
+11
-4
@@ -2824,7 +2824,7 @@ async function trackEntity(
|
|||||||
() => {
|
() => {
|
||||||
let trackedOk = false;
|
let trackedOk = false;
|
||||||
if (family.kind === 'vessel') {
|
if (family.kind === 'vessel') {
|
||||||
trackedOk = !!module.selectById?.(found.mmsi);
|
trackedOk = !!module.selectById?.(found.id ?? found.mmsi);
|
||||||
flyToLandmark(viewer, found.latitude, found.longitude, {
|
flyToLandmark(viewer, found.latitude, found.longitude, {
|
||||||
range: 6000,
|
range: 6000,
|
||||||
pitch: -45,
|
pitch: -45,
|
||||||
@@ -4003,8 +4003,13 @@ function locateLayerEntity(dataManager, { layerId, id, lat, lon }) {
|
|||||||
?.module?.getAnalystRecords?.()
|
?.module?.getAnalystRecords?.()
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
String(item.icao24 ?? item.mmsi ?? item.noradId ?? item.id) ===
|
String(
|
||||||
String(id),
|
item.icao24 ??
|
||||||
|
item.vesselId ??
|
||||||
|
item.mmsi ??
|
||||||
|
item.noradId ??
|
||||||
|
item.id,
|
||||||
|
) === String(id),
|
||||||
) || [];
|
) || [];
|
||||||
// Bikeshare uses station names as IDs: retain the coordinate identity
|
// Bikeshare uses station names as IDs: retain the coordinate identity
|
||||||
// even if a namesake disappears. Moving records use their fresh fix.
|
// 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. */
|
/** Identity fields every compact analyst item keeps when present. */
|
||||||
const ANALYST_IDENTITY_FIELDS = Object.freeze([
|
const ANALYST_IDENTITY_FIELDS = Object.freeze([
|
||||||
'icao24',
|
'icao24',
|
||||||
|
'vesselId',
|
||||||
'mmsi',
|
'mmsi',
|
||||||
'registration',
|
'registration',
|
||||||
'label',
|
'label',
|
||||||
@@ -5361,6 +5367,7 @@ const ANALYST_CANONICAL_FIELDS = new Set([
|
|||||||
'layerKey',
|
'layerKey',
|
||||||
'id',
|
'id',
|
||||||
'icao24',
|
'icao24',
|
||||||
|
'vesselId',
|
||||||
'mmsi',
|
'mmsi',
|
||||||
'noradId',
|
'noradId',
|
||||||
'registration',
|
'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
|
* 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.
|
* 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
|
* hand this result straight to track_entity, and `id` is a DISPLAY label
|
||||||
* (callsign, else registration, else hex). A callsign-less contact therefore
|
* (callsign, else registration, else hex). A callsign-less contact therefore
|
||||||
* handed track_entity a tail number the lookup could not resolve, and the
|
* 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', () => {
|
test('P1-1: a result from before the last clear cannot repopulate the registry', () => {
|
||||||
const referents = createReferentRegistry();
|
const referents = createReferentRegistry();
|
||||||
const since = referents.generation();
|
const since = referents.generation();
|
||||||
|
|||||||
+10
-3
@@ -23,10 +23,17 @@ function finite(value) {
|
|||||||
|
|
||||||
function normalize(entry, index) {
|
function normalize(entry, index) {
|
||||||
if (!entry || typeof entry !== 'object') return null;
|
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
|
// display `id` such as a callsign is only a label, and two contacts can
|
||||||
// share one.
|
// share one. Vessels can share an MMSI too, so a vessel's key is its
|
||||||
const id = entry.icao24 ?? entry.mmsi ?? entry.noradId ?? entry.id ?? null;
|
// `vesselId` when the row names one.
|
||||||
|
const id =
|
||||||
|
entry.icao24 ??
|
||||||
|
entry.vesselId ??
|
||||||
|
entry.mmsi ??
|
||||||
|
entry.noradId ??
|
||||||
|
entry.id ??
|
||||||
|
null;
|
||||||
const label = String(
|
const label = String(
|
||||||
entry.label ?? entry.callsign ?? entry.name ?? entry.id ?? id ?? '',
|
entry.label ?? entry.callsign ?? entry.name ?? entry.id ?? id ?? '',
|
||||||
).trim();
|
).trim();
|
||||||
|
|||||||
+1
-1
@@ -501,7 +501,7 @@ function frameOverhead(result) {
|
|||||||
.filter((entry) => entry?.label || entry?.id)
|
.filter((entry) => entry?.label || entry?.id)
|
||||||
.map((entry, index) => ({
|
.map((entry, index) => ({
|
||||||
n: index + 1,
|
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),
|
label: spokenLabel(entry.label || entry.id),
|
||||||
layerId: result.layerId,
|
layerId: result.layerId,
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in new issue
Block a user