mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 05:42:22 +00:00
Merge pull request #1662 from bilawalsidhu/sameh/contacts-follow-world-clock
feat(live): flights, military and vessels follow the world clock; vessels keyed by source id; share a selected vessel
This commit is contained in:
109 files changed
+4649
-808
No files matched your search
@@ -0,0 +1,36 @@
|
||||
- Flights, Military Flights and AIS Vessels follow the world clock when their
|
||||
source declares a timeline, that is, can answer a snapshot at a given time
|
||||
(`source.timeline`, read by `readSnapshotTimeline` from
|
||||
`gods-eye-view/sources/live`). Away from live they show the snapshot at the
|
||||
clock's time and move on with it as it plays; back at live they poll as
|
||||
before. A source'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, #1662).
|
||||
- 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, #1662).
|
||||
- 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, #1662).
|
||||
@@ -634,8 +634,9 @@ source-time fallback and model-owned ground policy. The renderer supplies that
|
||||
ownership fact and handles stale-ground lifting, Cesium history and primitives;
|
||||
record reconciliation does not import the engine or application.
|
||||
|
||||
`layers/vessels/records` owns plain AIS metadata, stable MMSI identity and bounded
|
||||
retention. `layers/vessels/ingestion` owns source requests and feed state through
|
||||
`layers/vessels/records` owns plain AIS metadata, identity by the stable id the
|
||||
source gives (an MMSI only for a feed that has nothing else; the MMSI itself is
|
||||
an attribute) and bounded retention. `layers/vessels/ingestion` owns source requests and feed state through
|
||||
explicit operations. Both exports exclude Cesium, DOM and application assembly.
|
||||
The snapshot renderer applies record changes; rendering owns a weak map of
|
||||
geometry and billboard resources used by cards, picking and trails.
|
||||
|
||||
@@ -1589,6 +1589,47 @@ messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
|
||||
Sources receive cancellation signals and check them after body parsing. The
|
||||
layer's existing lifecycle and selection guards continue to reject late work.
|
||||
|
||||
### Following the world clock
|
||||
|
||||
A source may declare a timeline: it can answer a snapshot at a given time.
|
||||
`source.timeline` is `{ coverage(), stepMs, subscribe? }`, validated by
|
||||
`readSnapshotTimeline` (`src/sources/live/contract.js`): `coverage()` returns
|
||||
the span `{ start, end }` in epoch ms it can answer, or null while it does not
|
||||
know yet; `stepMs` is the spacing of its snapshots; the optional
|
||||
`subscribe(listener)` tells of a change in coverage. Flights, Military Flights
|
||||
and AIS Vessels follow the world clock only while their source declares one
|
||||
(`src/time/snapshotTimeline.js`); with none they never follow it, and every
|
||||
request, timing and display stays as it is live.
|
||||
|
||||
While such a layer is on it follows the clock and registers the source's
|
||||
coverage on the timeline. At the live edge it polls as it always has. Away
|
||||
from it the layer stops polling and asks for `getSnapshot({ ...query, atMs })`
|
||||
at the step at or before the clock's time; a request at the live edge carries
|
||||
no `atMs`. A seek, the time going backwards or leaving live is a jump (the
|
||||
clock's `getState().jumps` counts seeks and returns to live, not play): what
|
||||
loads is dropped, play holds while the snapshot loads, and the snapshot then
|
||||
stands in for every contact. A contact it lacks is gone at once; a tracked or
|
||||
selected contact it lacks is released, and one it has starts its trail afresh,
|
||||
whose track request carries `atMs` and draws only points at or before it.
|
||||
Playing, the layer stays a snapshot ahead of the time, and play does not pass a
|
||||
snapshot still loading for more than 8 seconds. Those snapshots apply as polls
|
||||
do, so contacts move on smoothly: aircraft are drawn at the clock's time
|
||||
rather than behind it, and vessels between two fixes move along the great
|
||||
circle, never past the newer one. Records, absences and the Layers panel's age
|
||||
are judged at the clock's time. Outside the coverage the layer shows no
|
||||
contacts and loads nothing; while the coverage is unknown it loads nothing and
|
||||
clears nothing. A snapshot that fails clears the time and is asked again after
|
||||
5 seconds; one the source drops for a newer request (an `AbortError`) is no
|
||||
failure, and nothing is cleared. A layer has one snapshot loading at a time:
|
||||
the older is aborted before a newer starts. Moving the view while vessels follow the clock asks for the new
|
||||
area at the same time. Back at live the contacts are cleared and the layer
|
||||
polls at once.
|
||||
|
||||
A snapshot at `atMs` describes the world as it stood then: `observedAtMs` is
|
||||
at or before `atMs`, no record is observed later than `atMs`, freshness and
|
||||
staleness are judged against `atMs`, `atMs` is echoed back, and a vessel
|
||||
snapshot reports `transportStatus: 'live'`.
|
||||
|
||||
## State and action outcomes
|
||||
|
||||
Share preferences, place lookups and Scene playback expose immutable snapshots
|
||||
|
||||
+41
-14
@@ -28,7 +28,11 @@ Following an aircraft is 42–50: 42 when the link is made from the app's own
|
||||
follow camera with the fix taken as the link is made, 44 with a fix four
|
||||
seconds older, and 50 after orbiting to another heading and pitch. A view in
|
||||
any other style that keeps the style's own look adds only the style (two
|
||||
bytes): the 42-character followed aircraft in Noir is 44.
|
||||
bytes): the 42-character followed aircraft in Noir is 44. A selected vessel
|
||||
named by its MMSI costs what an aircraft does (46 characters for the
|
||||
44-character aircraft above); one named by a UUID costs 13 bytes more, as its
|
||||
16 bytes travel raw (62 characters where the aircraft is 44, rather than about
|
||||
88 with the UUID as text).
|
||||
|
||||
## Opening a short link
|
||||
|
||||
@@ -47,9 +51,22 @@ best effort: until the entity is found, and if it is not found in time, the
|
||||
camera is where the link was made, looking at the entity's last-known fix,
|
||||
and the app says that the entity isn't reporting and when the fix was taken.
|
||||
The share button makes a thing view while the camera follows an aircraft,
|
||||
military aircraft or satellite. A selected vessel is not followed (the camera
|
||||
stays where the user puts it), so a link made then is a place view; vessel
|
||||
thing views open, but the app does not make them yet.
|
||||
military aircraft or satellite. While the camera follows nothing and a vessel
|
||||
is selected, it makes a vessel thing view: the fix is the vessel's position
|
||||
as drawn (as of the world time while the vessel layer follows the world
|
||||
clock, else as of its latest position report), and the offset is the one whose derived camera is
|
||||
where the user's camera is (`followOffsetFor` in `src/share/follow.js`, the
|
||||
inverse of `followCameraFor`, taken from the fix at the format's precision).
|
||||
The opened camera is then within the offset's whole metres and degrees of
|
||||
where the link was made, looking at the vessel. When no offset can carry the
|
||||
camera, because it is not above the vessel's local horizontal (it would look
|
||||
up at the vessel, or through the earth) or its range is outside 1 m to
|
||||
10⁸ m, the link is a place view as before. A vessel is selected, not
|
||||
followed, so the camera stays where the user puts it. Following an entity
|
||||
takes priority over a selected vessel, and a followed entity the format
|
||||
cannot name still makes a place view. Opening a vessel thing view with a
|
||||
world time waits for the vessel layer's snapshot at that time and selects the
|
||||
vessel once it has it.
|
||||
|
||||
## Payload
|
||||
|
||||
@@ -274,14 +291,23 @@ sub-message:
|
||||
| 10 | fix alt | sint metres | 0 |
|
||||
| 11 | fix age | sint seconds the fix predates the created-at time | 0 |
|
||||
| 12 | observed-at | epoch, when the fix was taken; only without a created-at | none |
|
||||
| 13 | vessel id | UUID, 16 bytes (LEN) | |
|
||||
|
||||
The entity kind is the field that carries the id (its code in
|
||||
`SHARE_VIEW_ENTITY_KINDS`), so it costs nothing; exactly one of fields 1–4 is
|
||||
present (two is malformed). Entity ids are the ones the layers look up: an
|
||||
aircraft's ICAO 24-bit address (six hex digits, stored as a number; other
|
||||
forms such as TIS-B `~abc123` as text), a vessel's MMSI and a satellite's
|
||||
NORAD catalog number (decimal numbers; an MMSI with a leading zero stays
|
||||
text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`.
|
||||
`SHARE_VIEW_ENTITY_KINDS`, or field 13 for a vessel), so it costs nothing;
|
||||
exactly one of fields 1–4 and 13 is present (two is malformed). Entity ids are
|
||||
the ones the layers look up: an aircraft's ICAO 24-bit address (six hex
|
||||
digits, stored as a number; other forms such as TIS-B `~abc123` as text), a
|
||||
vessel's id (its MMSI, or a source's stable id, such as a UUID) and a
|
||||
satellite's NORAD catalog number (decimal numbers; an MMSI with a leading
|
||||
zero stays text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`, except a
|
||||
vessel id that is a UUID (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, lowercase
|
||||
hex): it is written as its 16 bytes in field 13 rather than as 36 characters
|
||||
of text in field 3, after the other thing fields, and decodes as the
|
||||
lowercase dashed UUID. Field 13 of any other length is malformed. A decoder
|
||||
that predates field 13 skips it, finds no entity and opens the view as a
|
||||
place at its derived camera (`unknown-entity-kind`), as for a new entity kind,
|
||||
so the field needed no new version.
|
||||
|
||||
Fields 5–7 are the follow offset: the camera looks along (heading, pitch) at
|
||||
the entity from `range` metres away, heading clockwise from north, in the
|
||||
@@ -319,8 +345,9 @@ bytes, a check byte that does not match (`checksum`), an unknown version byte,
|
||||
a truncated value, a varint over 8 bytes or above 2⁵³ − 1, an
|
||||
overlong varint, a LEN longer than what remains (at any level, including
|
||||
inside the thing sub-message), a reserved wire type, field number 0, a known
|
||||
field with the wrong wire type or repeated, two entity ids in one thing, a
|
||||
duplicate panel/option/style entry, non-ASCII text, and a latitude or
|
||||
field with the wrong wire type or repeated, two entity ids in one thing
|
||||
(field 13 counts as one), a UUID vessel id that is not 16 bytes
|
||||
(`bad-length`), a duplicate panel/option/style entry, non-ASCII text, and a latitude or
|
||||
longitude (camera or fix) beyond ±90/±180.
|
||||
|
||||
Tolerated and reported in `unknownFields`: unknown fields at any level
|
||||
@@ -358,8 +385,8 @@ the app that can open it.
|
||||
boolean takes the next flag bit instead, which costs nothing when it is at
|
||||
its default.
|
||||
6. **New view kinds and entity kinds** get the next code. A new entity kind's
|
||||
id takes the next free thing field (13 onwards), so older apps skip it and
|
||||
fall back to the camera. A thing that needs more than an id (for example a
|
||||
id takes the next free thing field (14 onwards; 13 is a vessel id that is
|
||||
a UUID), so older apps skip it and fall back to the camera. A thing that needs more than an id (for example a
|
||||
CCTV camera, a radio station or a transit vehicle) adds further thing
|
||||
fields the same way.
|
||||
7. **A new layer option** needs an entry in `SHARE_VIEW_LAYER_OPTION_ABSENT`
|
||||
|
||||
+1
-1
@@ -270,7 +270,7 @@ and the development server's internal routes. See SECURITY.md.
|
||||
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
|
||||
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match |
|
||||
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
|
||||
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
|
||||
| `get_vessel_track` | `vessels` | Recent positions of one ship by vessel id or MMSI, thinned to 200 points |
|
||||
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
|
||||
| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a place or point now, highest first |
|
||||
| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first |
|
||||
|
||||
@@ -1505,6 +1505,9 @@
|
||||
"src/layers/flights/tracking.js",
|
||||
"src/data/trailContinuity.js",
|
||||
"src/layers/flights/records.js",
|
||||
"src/layers/flights/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/flights/recordPolicy.js",
|
||||
"src/layers/flights/snapshotRenderer.js",
|
||||
"src/layers/flights/modelSpec.js",
|
||||
@@ -1560,6 +1563,9 @@
|
||||
"src/layers/military/controller.js",
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/military/ingestion.js",
|
||||
"src/layers/military/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/military/lifecycle.js",
|
||||
"src/layers/military/motion.js",
|
||||
"src/layers/military/policy.js",
|
||||
@@ -1634,6 +1640,10 @@
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/motion.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/vessels/cameraArea.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
"src/layers/vessels/lifecycle.js",
|
||||
@@ -2782,6 +2792,8 @@
|
||||
"src/layers/flights/enrichment.js",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/index.js",
|
||||
"src/layers/flights/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/lifecycle.js",
|
||||
"src/layers/flights/motion.js",
|
||||
@@ -2823,6 +2835,7 @@
|
||||
"src/layers/launches/testing.js",
|
||||
"src/layers/military/controller.js",
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/military/clockFollow.js",
|
||||
"src/layers/military/ingestion.js",
|
||||
"src/layers/military/lifecycle.js",
|
||||
"src/layers/military/motion.js",
|
||||
@@ -2905,6 +2918,7 @@
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/motion.js",
|
||||
"src/layers/vessels/cameraArea.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
@@ -3792,6 +3806,9 @@
|
||||
"src/layers/flights/enrichment.js",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/index.js",
|
||||
"src/layers/flights/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/lifecycle.js",
|
||||
"src/layers/flights/motion.js",
|
||||
@@ -3833,6 +3850,7 @@
|
||||
"src/layers/launches/testing.js",
|
||||
"src/layers/military/controller.js",
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/military/clockFollow.js",
|
||||
"src/layers/military/ingestion.js",
|
||||
"src/layers/military/lifecycle.js",
|
||||
"src/layers/military/motion.js",
|
||||
@@ -3914,6 +3932,7 @@
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/motion.js",
|
||||
"src/layers/vessels/cameraArea.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
|
||||
@@ -6,6 +6,7 @@ import assert from 'node:assert/strict';
|
||||
import { mapAnalystRecord } from './aisLiveVessels.js';
|
||||
|
||||
const FULL_RECORD = {
|
||||
id: '353136000',
|
||||
mmsi: '353136000',
|
||||
name: 'EVER GIVEN',
|
||||
lat: 29.55,
|
||||
@@ -20,6 +21,7 @@ test('ais analyst record: full record maps every contract field', () => {
|
||||
const r = mapAnalystRecord(FULL_RECORD);
|
||||
assert.deepEqual(r, {
|
||||
id: 'EVER GIVEN',
|
||||
vesselId: '353136000',
|
||||
mmsi: '353136000',
|
||||
name: 'EVER GIVEN',
|
||||
lat: 29.55,
|
||||
@@ -38,6 +40,16 @@ test('ais analyst record: nameless vessel falls back to mmsi id', () => {
|
||||
assert.equal(r.name, null);
|
||||
});
|
||||
|
||||
test('ais analyst record: the key is the source id; the MMSI rides along when known', () => {
|
||||
const shared = mapAnalystRecord({ ...FULL_RECORD, id: 'provider-a' });
|
||||
assert.equal(shared.vesselId, 'provider-a');
|
||||
assert.equal(shared.mmsi, '353136000');
|
||||
const noMmsi = mapAnalystRecord({ ...FULL_RECORD, id: 'provider-b', mmsi: '', name: '' });
|
||||
assert.equal(noMmsi.vesselId, 'provider-b');
|
||||
assert.equal(noMmsi.mmsi, null);
|
||||
assert.equal(noMmsi.id, 'provider-b', 'a nameless vessel with no MMSI is labelled by its id');
|
||||
});
|
||||
|
||||
test('ais analyst record: empty strings and NaN become null, never undefined', () => {
|
||||
const r = mapAnalystRecord({ mmsi: '', name: 'TUG', speed: NaN, type: '', destination: '' });
|
||||
assert.equal(r.mmsi, null);
|
||||
|
||||
@@ -287,7 +287,7 @@ test('zero accepted rows preserve warm vessel selection, trail, and freshness ti
|
||||
records: [record],
|
||||
selectedRecord: record,
|
||||
trail,
|
||||
trailMmsi: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositions: [POS],
|
||||
loaded: true,
|
||||
lastUpdate: 456,
|
||||
@@ -314,8 +314,8 @@ test('zero accepted rows preserve warm vessel selection, trail, and freshness ti
|
||||
lastMessageAt: 789,
|
||||
rawRowCount: 1,
|
||||
acceptedRowCount: 0,
|
||||
selectedMmsi: record.mmsi,
|
||||
trailMmsi: record.mmsi,
|
||||
selectedId: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositionCount: 1,
|
||||
sessionId: _getVesselFeedStateForTest().sessionId,
|
||||
firstConnectPhase: 'idle',
|
||||
@@ -565,7 +565,7 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
|
||||
records: [replacement],
|
||||
selectedRecord: replacement,
|
||||
trail: replacementTrail,
|
||||
trailMmsi: replacement.mmsi,
|
||||
trailId: replacement.mmsi,
|
||||
trailPositions: [POS],
|
||||
loaded: true,
|
||||
lastUpdate: 200,
|
||||
@@ -581,7 +581,7 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
|
||||
let feed = _getVesselFeedStateForTest();
|
||||
assert.equal(feed.loading, true, 'old finally must not clear replacement loading ownership');
|
||||
assert.equal(feed.lastUpdate, 200, 'old response must not advance replacement freshness');
|
||||
assert.equal(feed.selectedMmsi, replacement.mmsi);
|
||||
assert.equal(feed.selectedId, replacement.mmsi);
|
||||
|
||||
second.resolve(jsonResponse({ status: 'open', lastMessageAt: null, rows: [] }));
|
||||
await replacementLoad;
|
||||
@@ -590,8 +590,8 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
|
||||
assert.equal(feed.lastUpdate, 200);
|
||||
assert.equal(feed.error, 'awaiting first AIS message…');
|
||||
assert.equal(feed.stale, true);
|
||||
assert.equal(feed.selectedMmsi, replacement.mmsi);
|
||||
assert.equal(feed.trailMmsi, replacement.mmsi);
|
||||
assert.equal(feed.selectedId, replacement.mmsi);
|
||||
assert.equal(feed.trailId, replacement.mmsi);
|
||||
assert.equal(replacementTrail.clearCalls, 0);
|
||||
} finally {
|
||||
globalThis.fetch = priorFetch;
|
||||
@@ -784,21 +784,21 @@ test('vessel focus wire restores a hidden sprite before releasing the active pas
|
||||
|
||||
test('vessel selection: empty-space click requests deselection', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({ selectedMmsi: '353136000', gesture: 'click' }),
|
||||
reduceVesselSelection({ selectedId: '353136000', gesture: 'click' }),
|
||||
{ action: 'deselect' },
|
||||
);
|
||||
});
|
||||
|
||||
test('vessel selection: no selection plus empty-space click is a no-op', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({ selectedMmsi: null, pickedMmsi: null, gesture: 'click' }),
|
||||
reduceVesselSelection({ selectedId: null, pickedId: null, gesture: 'click' }),
|
||||
{ action: 'none' },
|
||||
);
|
||||
});
|
||||
|
||||
test('vessel selection: Escape deselects only when this layer owns a selection', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({ selectedMmsi: 353136000, gesture: 'escape' }),
|
||||
reduceVesselSelection({ selectedId: 353136000, gesture: 'escape' }),
|
||||
{ action: 'deselect' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
@@ -810,16 +810,16 @@ test('vessel selection: Escape deselects only when this layer owns a selection',
|
||||
test('vessel selection: another vessel replaces selection; same vessel is a no-op', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({
|
||||
selectedMmsi: '353136000',
|
||||
pickedMmsi: '367123450',
|
||||
selectedId: '353136000',
|
||||
pickedId: '367123450',
|
||||
gesture: 'click',
|
||||
}),
|
||||
{ action: 'select' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({
|
||||
selectedMmsi: '353136000',
|
||||
pickedMmsi: '353136000',
|
||||
selectedId: '353136000',
|
||||
pickedId: '353136000',
|
||||
gesture: 'click',
|
||||
}),
|
||||
{ action: 'none' },
|
||||
@@ -834,8 +834,9 @@ test('vessel selection: another vessel replaces selection; same vessel is a no-o
|
||||
|
||||
const POS = Cesium.Cartesian3.fromDegrees(4.05, 51.93, 3);
|
||||
|
||||
// Like the layer's records, a vessel with no id of its own is keyed by its MMSI.
|
||||
function makeRecord(overrides = {}) {
|
||||
return {
|
||||
const record = {
|
||||
name: 'EVER GIVEN',
|
||||
mmsi: '353136000',
|
||||
type: 'Container Ship',
|
||||
@@ -849,6 +850,8 @@ function makeRecord(overrides = {}) {
|
||||
billboard: null,
|
||||
...overrides,
|
||||
};
|
||||
if (!Object.hasOwn(overrides, 'id')) record.id = record.mmsi;
|
||||
return record;
|
||||
}
|
||||
|
||||
function makeTrailSpy() {
|
||||
@@ -948,7 +951,7 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
records: [record],
|
||||
selectedRecord: record,
|
||||
trail,
|
||||
trailMmsi: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositions: [POS],
|
||||
interactionHandlerFactory,
|
||||
interactionKeyTarget: keyTarget,
|
||||
@@ -983,7 +986,7 @@ test('vessel interaction wire: trail pick does not deselect', () => {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||||
assert.equal(harness.trail.clearCalls, 0);
|
||||
assert.equal(_getVesselStateForTest().trailMmsi, harness.record.mmsi);
|
||||
assert.equal(_getVesselStateForTest().trailId, harness.record.mmsi);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
@@ -1213,7 +1216,7 @@ test('vessel interaction lifecycle: disable detaches input and enable reinstalls
|
||||
selectedRecord: harness.record,
|
||||
enabled: false,
|
||||
trail: harness.trail,
|
||||
trailMmsi: harness.record.mmsi,
|
||||
trailId: harness.record.mmsi,
|
||||
trailPositions: [POS],
|
||||
interactionHandlerFactory: harness.interactionHandlerFactory,
|
||||
interactionKeyTarget: harness.keyTarget,
|
||||
@@ -1287,7 +1290,7 @@ test('vessel trail lifecycle: deselect clears the selected-vessel trail', () =>
|
||||
aisLiveVesselsLayer.clearSelection();
|
||||
assert.equal(harness.trail.clearCalls, 1);
|
||||
assert.deepEqual(_getVesselStateForTest(), {
|
||||
trailMmsi: null,
|
||||
trailId: null,
|
||||
trailPositionCount: 0,
|
||||
vesselCount: 1,
|
||||
});
|
||||
@@ -1302,14 +1305,14 @@ test('vessel trail lifecycle: reconciliation eviction clears an orphaned trail',
|
||||
_setVesselStateForTest({
|
||||
records: [record],
|
||||
trail,
|
||||
trailMmsi: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositions: [POS],
|
||||
});
|
||||
try {
|
||||
_reconcileVesselsForTest({}, []);
|
||||
assert.equal(trail.clearCalls, 1);
|
||||
assert.deepEqual(_getVesselStateForTest(), {
|
||||
trailMmsi: null,
|
||||
trailId: null,
|
||||
trailPositionCount: 0,
|
||||
vesselCount: 0,
|
||||
});
|
||||
@@ -1566,6 +1569,28 @@ test('vessel interaction wire: selecting a vessel by click requests a camera tra
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel interaction wire: a vessel with an id and no MMSI is selected by click', () => {
|
||||
const idOnly = makeRecord({ id: 'provider-7', mmsi: '', name: 'NO MMSI' });
|
||||
const harness = installWireHarness(
|
||||
{ id: idOnly },
|
||||
{ records: [idOnly], selectedRecord: null, trailId: null },
|
||||
);
|
||||
const requests = [];
|
||||
harness.windowTarget.addEventListener(
|
||||
WORLD_FOCUS_REQUEST_EVENT,
|
||||
(event) => requests.push(event.detail),
|
||||
);
|
||||
try {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.id, 'provider-7');
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, '');
|
||||
assert.equal(requests[0]?.id, 'provider-7');
|
||||
assert.match(harness.hud.readout.text, /MMSI: -- /);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Overlay-host recorder whose hitTest answers with one card id. The real host
|
||||
* only publishes rects for `interactive` entries, so a stub returning a hit is
|
||||
@@ -1689,11 +1714,27 @@ for (const card of ['live', 'stale', 'absent', 'covered']) {
|
||||
});
|
||||
}
|
||||
|
||||
test('vessel card policy: only MMSI-keyed cards publish a hit rect', () => {
|
||||
test('vessel card policy: only keyed cards publish a hit rect', () => {
|
||||
const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord()));
|
||||
assert.equal(keyed.interactive, true);
|
||||
const unkeyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord({ mmsi: '' })));
|
||||
assert.equal(unkeyed.interactive, false, 'an unkeyed card has no record to select');
|
||||
const idOnly = buildVesselCard(makeRecord({ id: 'provider-7', mmsi: '' }));
|
||||
assert.equal(applyVesselOverlayPolicy(idOnly).interactive, true, 'a source id is a key');
|
||||
assert.equal(idOnly.id, 'vessel:provider-7');
|
||||
});
|
||||
|
||||
test('vessel cards are keyed by id and show the MMSI only when known', () => {
|
||||
const shared = buildSelectedVesselCard(makeRecord({ id: 'hull-b' }));
|
||||
assert.equal(shared.id, 'vessel:hull-b');
|
||||
assert.equal(shared.actionable, true);
|
||||
assert.match(shared.details.at(-1), /^MMSI 353136000 · /);
|
||||
const unknown = buildSelectedVesselCard(
|
||||
makeRecord({ id: 'provider-7', mmsi: '', name: 'provider-7' }),
|
||||
);
|
||||
assert.equal(unknown.actionable, true);
|
||||
assert.match(unknown.details.at(-1), /^MMSI -- · /);
|
||||
assert.equal(unknown.title, 'VESSEL', 'a name that is only the id is no name');
|
||||
});
|
||||
|
||||
test('vessel interaction wire: deselecting never moves the camera', () => {
|
||||
@@ -1740,7 +1781,7 @@ test('vessel selection passes the opaque source reference to optional history',
|
||||
getSnapshot: async () => ({ records: [] }),
|
||||
getTrack: async (reference, options) => { requests.push({ reference, options }); return { records: [] }; },
|
||||
});
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null, trailMmsi: null });
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null, trailId: null });
|
||||
try {
|
||||
harness.record.reference = 'opaque:test-reference';
|
||||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||
|
||||
@@ -276,7 +276,7 @@ test('a click on neither sprite nor card clears without moving the camera', () =
|
||||
const SIBLING_PICKS = [
|
||||
['flights', 'a1b2c3', { id: 'a1b2c3' }],
|
||||
['flights', 'a1b2c3', { primitive: { id: 'a1b2c3' } }],
|
||||
['ais-live-vessels', '353136000', { id: { mmsi: '353136000', name: 'EVER GIVEN' } }],
|
||||
['ais-live-vessels', '353136000', { id: { id: '353136000', mmsi: '353136000', name: 'EVER GIVEN' } }],
|
||||
['cctv', 'atx-cam-3', { id: { id: 'atx-cam-3', name: 'entity-like' } }],
|
||||
['satellites', '25544', { id: 25544 }],
|
||||
];
|
||||
|
||||
@@ -1807,7 +1807,7 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
|
||||
[
|
||||
'aisLiveVessels',
|
||||
readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)),
|
||||
/hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byMmsi\s*\|\|\s*state\.records\.byMmsi\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
|
||||
/hasContact\(\s*id,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byId\s*\|\|\s*state\.records\.byId\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
|
||||
],
|
||||
]) {
|
||||
assert.match(
|
||||
|
||||
@@ -61,9 +61,9 @@ export function formatAwarenessLabel(value) {
|
||||
value.callsign,
|
||||
value.name,
|
||||
value.label,
|
||||
value.mmsi,
|
||||
value.id,
|
||||
value.icao24,
|
||||
value.mmsi,
|
||||
]) {
|
||||
const label = formatAwarenessLabel(candidate);
|
||||
if (label !== '—') return label;
|
||||
@@ -154,7 +154,7 @@ export function getAwarenessNavigationTargets(
|
||||
? cohort.summary.nearest
|
||||
: [];
|
||||
for (const item of sourceItems) {
|
||||
const id = item?.icao24 || item?.mmsi || item?.id;
|
||||
const id = item?.icao24 || item?.id || item?.mmsi;
|
||||
if (!id) continue;
|
||||
const key = `${layerId}:${id}`;
|
||||
if (key === `${subject?.layerId}:${subject?.id}`) continue;
|
||||
|
||||
@@ -21,7 +21,7 @@ const _owners = new Map();
|
||||
*
|
||||
* Layers use heterogeneous pick ids: flights/military/bikeshare/CCTV use
|
||||
* strings, satellites use numeric NORAD catalog ids, live AIS vessels attach
|
||||
* the vessel record OBJECT (identity = its `mmsi`), and entity picks surface
|
||||
* the vessel record OBJECT (identity = its `id`), and entity picks surface
|
||||
* the Cesium Entity (identity = its string `id`). Everything is coerced to a
|
||||
* String so predicates match against one canonical form.
|
||||
*
|
||||
@@ -33,9 +33,7 @@ export function resolvePickId(picked) {
|
||||
const unwrap = (id) => {
|
||||
if (id === null || id === undefined) return undefined;
|
||||
if (typeof id === 'object') {
|
||||
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string)
|
||||
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number')
|
||||
return id.mmsi;
|
||||
// AIS vessel record or Cesium Entity: both are keyed by their `.id`.
|
||||
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -33,10 +33,12 @@ test('resolvePickId: numeric pick id (satellite NORAD numbers) → String', () =
|
||||
assert.equal(resolvePickId({ id: undefined, primitive: { id: 43013 } }), '43013');
|
||||
});
|
||||
|
||||
test('resolvePickId: AIS vessel record object → its mmsi', () => {
|
||||
const record = { mmsi: '367123450', name: 'EVER GIVEN', billboard: {} };
|
||||
test('resolvePickId: AIS vessel record object → its id, not its MMSI', () => {
|
||||
const record = { id: '367123450', mmsi: '367123450', name: 'EVER GIVEN', billboard: {} };
|
||||
assert.equal(resolvePickId({ id: record }), '367123450');
|
||||
assert.equal(resolvePickId({ primitive: { id: record } }), '367123450');
|
||||
const shared = { id: 'hull-b', mmsi: '367123450', name: 'EVER GIVEN' };
|
||||
assert.equal(resolvePickId({ id: shared }), 'hull-b');
|
||||
});
|
||||
|
||||
test('resolvePickId: Cesium-Entity-like object → its string id', () => {
|
||||
@@ -48,7 +50,7 @@ test('resolvePickId: unresolvable picks → null', () => {
|
||||
assert.equal(resolvePickId(null), null);
|
||||
assert.equal(resolvePickId(undefined), null);
|
||||
assert.equal(resolvePickId({}), null);
|
||||
assert.equal(resolvePickId({ id: {} }), null); // object with no mmsi/id
|
||||
assert.equal(resolvePickId({ id: {} }), null); // object with no id
|
||||
assert.equal(resolvePickId({ primitive: {} }), null);
|
||||
});
|
||||
|
||||
@@ -67,8 +69,8 @@ test('ownership: sibling layers recognize each other via String-coerced ids', ()
|
||||
try {
|
||||
// Flights asking about a satellite pick (raw numeric id → String upstream)
|
||||
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: 25544 })), true);
|
||||
// Flights asking about a vessel pick (record object → mmsi)
|
||||
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: { mmsi: '367123450' } })), true);
|
||||
// Flights asking about a vessel pick (record object → id)
|
||||
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: { id: '367123450', mmsi: '367123450' } })), true);
|
||||
// A layer never owns its own pick via the sibling scan
|
||||
assert.equal(isOwnedByOtherLayer('satellites', '25544'), false);
|
||||
// Unknown ids belong to nobody
|
||||
@@ -147,7 +149,7 @@ test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CS
|
||||
test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) {
|
||||
for (const first of [{ id: 'military' }, { id: { id: '123456789', mmsi: '123456789' } }]) {
|
||||
const scene = {
|
||||
pick: () => mapTile,
|
||||
drillPick: () => [mapTile, first, { id: 'civil' }],
|
||||
|
||||
@@ -64,8 +64,8 @@ function contextCarrierRecord(picked, dataManager) {
|
||||
function fromRecord(record) {
|
||||
const layerId = record.layerId || null;
|
||||
const id =
|
||||
layerId === 'ais-live-vessels' && record.properties?.mmsi
|
||||
? String(record.properties.mmsi)
|
||||
layerId === 'ais-live-vessels' && record.properties?.vesselId
|
||||
? String(record.properties.vesselId)
|
||||
: String(record.id);
|
||||
return {
|
||||
layerId,
|
||||
|
||||
@@ -155,7 +155,7 @@ export function applyVesselOverlayPolicy(
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
// Only MMSI-keyed cards can resolve back to one actionable vessel.
|
||||
// Only keyed cards can resolve back to one actionable vessel.
|
||||
interactive: card?.actionable === true,
|
||||
};
|
||||
}
|
||||
@@ -2676,8 +2676,6 @@
|
||||
"Shared flight is unavailable": "الرحلة المشتركة غير متاحة",
|
||||
"Shared military flight is unavailable": "الرحلة العسكرية المشتركة غير متاحة",
|
||||
"Shared satellite is unavailable": "القمر الصناعي المشترك غير متاح",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id}: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id}: لا بيانات واردة الآن — يُعرض الموضع عند {time}",
|
||||
"This share link can't be opened": "لا يمكن فتح رابط المشاركة هذا",
|
||||
"Shared vessel is unavailable": "السفينة المشتركة غير متاحة",
|
||||
"subject": "الهدف",
|
||||
@@ -3778,5 +3776,17 @@
|
||||
"Play speed": "سرعة التشغيل",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل",
|
||||
"1 min/s": "دقيقة/ث",
|
||||
"Photo © {photographer}": "صورة © {photographer}"
|
||||
"Photo © {photographer}": "صورة © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "الرحلة العسكرية المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "الرحلة العسكرية المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "السفينة المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "السفينة المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "القمر الصناعي المشترك: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "القمر الصناعي المشترك: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "معرّف UUID للسفينة: {id}"
|
||||
}
|
||||
@@ -2528,8 +2528,6 @@
|
||||
"Shared flight is unavailable": "Geteilter Flug ist nicht verfügbar",
|
||||
"Shared military flight is unavailable": "Geteilter Militärflug ist nicht verfügbar",
|
||||
"Shared satellite is unavailable": "Geteilter Satellit ist nicht verfügbar",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} sendet gerade nicht – letzte bekannte Position wird gezeigt",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} sendet gerade nicht – Position um {time} wird gezeigt",
|
||||
"This share link can't be opened": "Dieser Freigabelink kann nicht geöffnet werden",
|
||||
"Shared vessel is unavailable": "Geteiltes Schiff ist nicht verfügbar",
|
||||
"subject": "Objekt",
|
||||
@@ -3454,5 +3452,17 @@
|
||||
"Play speed": "Wiedergabegeschwindigkeit",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles",
|
||||
"1 min/s": "1 Min./s",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Der geteilte Flug sendet gerade nicht – letzte bekannte Position wird gezeigt",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Der geteilte Flug sendet gerade nicht – Position um {time} wird gezeigt",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Der geteilte Militärflug sendet gerade nicht – letzte bekannte Position wird gezeigt",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Der geteilte Militärflug sendet gerade nicht – Position um {time} wird gezeigt",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "Das geteilte Schiff sendet gerade nicht – letzte bekannte Position wird gezeigt",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "Das geteilte Schiff sendet gerade nicht – Position um {time} wird gezeigt",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Der geteilte Satellit sendet gerade nicht – letzte bekannte Position wird gezeigt",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Der geteilte Satellit sendet gerade nicht – Position um {time} wird gezeigt",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "Schiffs-UUID: {id}"
|
||||
}
|
||||
@@ -427,8 +427,6 @@
|
||||
"Link copied!": "¡Enlace copiado!",
|
||||
"Link shared": "Enlace compartido",
|
||||
"This share link can't be opened": "Este enlace compartido no se puede abrir",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"Shared vessel is unavailable": "El buque compartido no está disponible",
|
||||
"Copy failed": "No se pudo copiar",
|
||||
"Location not found": "Ubicación no encontrada",
|
||||
@@ -3535,5 +3533,17 @@
|
||||
"Play speed": "Velocidad de reproducción",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo",
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "El vuelo compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "El vuelo compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "El buque compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "El buque compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "El satélite compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "El satélite compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID del buque: {id}"
|
||||
}
|
||||
@@ -2287,8 +2287,6 @@
|
||||
"Shared {subject} follow expired": "Suivi partagé expiré : {subject}",
|
||||
"Shared {subject} could not be restored — feed unavailable": "Suivi partagé non restauré : {subject} — flux indisponible",
|
||||
"Shared {subject} is unavailable": "Partage indisponible : {subject}",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"This share link can't be opened": "Ce lien de partage ne peut pas être ouvert",
|
||||
"Shared vessel is unavailable": "Le navire partagé est indisponible",
|
||||
"ACQUIRING": "ACQUISITION",
|
||||
@@ -3535,5 +3533,17 @@
|
||||
"Play speed": "Vitesse de lecture",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir",
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Photo © {photographer}"
|
||||
"Photo © {photographer}": "Photo © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Le vol partagé n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Le vol partagé n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Le vol militaire partagé n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Le vol militaire partagé n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "Le navire partagé n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "Le navire partagé n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Le satellite partagé n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Le satellite partagé n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"ICAO: {id}": "ICAO : {id}",
|
||||
"NORAD: {id}": "NORAD : {id}",
|
||||
"MMSI: {id}": "MMSI : {id}",
|
||||
"Vessel UUID: {id}": "UUID du navire : {id}"
|
||||
}
|
||||
@@ -2119,8 +2119,6 @@
|
||||
"Shared {subject} follow expired": "עקיבת {subject} המשותף פגה",
|
||||
"Shared {subject} could not be restored — feed unavailable": "לא ניתן לשחזר את {subject} המשותף — הפיד אינו זמין",
|
||||
"Shared {subject} is unavailable": "{subject} המשותף אינו זמין",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} לא מדווח עכשיו — מציג היכן נראה לאחרונה",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} לא מדווח עכשיו — מציג היכן היה ב-{time}",
|
||||
"This share link can't be opened": "לא ניתן לפתוח את קישור השיתוף הזה",
|
||||
"Shared vessel is unavailable": "האונייה המשותפת אינה זמינה",
|
||||
"ACQUIRING": "מאתר",
|
||||
@@ -3535,5 +3533,17 @@
|
||||
"other": "{count} ספינות טרופות"
|
||||
},
|
||||
"EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (מימי אירופה) · ויקינתונים (כל העולם)",
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ספינות טרופות אירופיות בעלות ערך מורשת מגיעות מ־EMODnet Human Activities; ספינות טרופות בולטות מכל העולם מגיעות מוויקינתונים. הצבע מראה מתי הספינה טבעה; הסמן גדל ככל שמספר ההרוגים עולה. לחץ על סמן כדי לפתוח את הכרטיס שלו."
|
||||
"European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ספינות טרופות אירופיות בעלות ערך מורשת מגיעות מ־EMODnet Human Activities; ספינות טרופות בולטות מכל העולם מגיעות מוויקינתונים. הצבע מראה מתי הספינה טבעה; הסמן גדל ככל שמספר ההרוגים עולה. לחץ על סמן כדי לפתוח את הכרטיס שלו.",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "הטיסה המשותפת לא מדווחת עכשיו — מוצג המיקום האחרון הידוע",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "הטיסה המשותפת לא מדווחת עכשיו — מוצג המיקום בשעה {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "הטיסה הצבאית המשותפת לא מדווחת עכשיו — מוצג המיקום האחרון הידוע",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "הטיסה הצבאית המשותפת לא מדווחת עכשיו — מוצג המיקום בשעה {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "כלי השיט המשותף לא מדווח עכשיו — מוצג המיקום האחרון הידוע",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "כלי השיט המשותף לא מדווח עכשיו — מוצג המיקום בשעה {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "הלוויין המשותף לא מדווח עכשיו — מוצג המיקום האחרון הידוע",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "הלוויין המשותף לא מדווח עכשיו — מוצג המיקום בשעה {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID של כלי השיט: {id}"
|
||||
}
|
||||
@@ -2287,8 +2287,6 @@
|
||||
"Shared {subject} follow expired": "Il tracciamento condiviso di {subject} è scaduto",
|
||||
"Shared {subject} could not be restored — feed unavailable": "Impossibile ripristinare la condivisione di {subject}: feed non disponibile",
|
||||
"Shared {subject} is unavailable": "La condivisione di {subject} non è disponibile",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} non sta trasmettendo ora: viene mostrata l'ultima posizione nota ({time})",
|
||||
"This share link can't be opened": "Impossibile aprire questo link di condivisione",
|
||||
"Shared vessel is unavailable": "La nave condivisa non è disponibile",
|
||||
"ACQUIRING": "ACQUISIZIONE",
|
||||
@@ -3535,5 +3533,17 @@
|
||||
"Play speed": "Velocità di riproduzione",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto",
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Il volo condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Il volo condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "La nave condivisa non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "La nave condivisa non sta trasmettendo ora: viene mostrata la posizione alle {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Il satellite condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Il satellite condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID della nave: {id}"
|
||||
}
|
||||
@@ -2209,8 +2209,6 @@
|
||||
"Shared {subject} follow expired": "共有された{subject}の追従が期限切れになりました",
|
||||
"Shared {subject} could not be restored — feed unavailable": "共有された{subject}を復元できませんでした:フィードを利用できません",
|
||||
"Shared {subject} is unavailable": "共有された{subject}は利用できません",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id}は現在報告していません。最後に確認された位置を表示しています",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id}は現在報告していません。{time}時点の位置を表示しています",
|
||||
"This share link can't be opened": "この共有リンクは開けません",
|
||||
"Shared vessel is unavailable": "共有された船舶は利用できません",
|
||||
"ACQUIRING": "取得中",
|
||||
@@ -3373,5 +3371,17 @@
|
||||
"Play speed": "再生速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示",
|
||||
"1 min/s": "1分/秒",
|
||||
"Photo © {photographer}": "写真 © {photographer}"
|
||||
"Photo © {photographer}": "写真 © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "共有されたフライトは現在報告していません。最後に確認された位置を表示しています",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "共有されたフライトは現在報告していません。{time}時点の位置を表示しています",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "共有された軍用機は現在報告していません。最後に確認された位置を表示しています",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "共有された軍用機は現在報告していません。{time}時点の位置を表示しています",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "共有された船舶は現在報告していません。最後に確認された位置を表示しています",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "共有された船舶は現在報告していません。{time}時点の位置を表示しています",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "共有された衛星は現在報告していません。最後に確認された位置を表示しています",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "共有された衛星は現在報告していません。{time}時点の位置を表示しています",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "船舶UUID: {id}"
|
||||
}
|
||||
@@ -2287,8 +2287,6 @@
|
||||
"Shared {subject} follow expired": "O acompanhamento compartilhado de {subject} expirou",
|
||||
"Shared {subject} could not be restored — feed unavailable": "Não foi possível restaurar o compartilhamento de {subject} — feed indisponível",
|
||||
"Shared {subject} is unavailable": "O compartilhamento de {subject} está indisponível",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} não está transmitindo agora — mostrando onde foi visto pela última vez",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} não está transmitindo agora — mostrando onde estava em {time}",
|
||||
"This share link can't be opened": "Não é possível abrir este link de compartilhamento",
|
||||
"Shared vessel is unavailable": "A embarcação compartilhada está indisponível",
|
||||
"ACQUIRING": "ADQUIRINDO",
|
||||
@@ -3535,5 +3533,17 @@
|
||||
"Play speed": "Velocidade de reprodução",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo",
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "O voo compartilhado não está transmitindo agora — mostrando a última posição conhecida",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "O voo compartilhado não está transmitindo agora — mostrando onde estava às {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "O voo militar compartilhado não está transmitindo agora — mostrando a última posição conhecida",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "O voo militar compartilhado não está transmitindo agora — mostrando onde estava às {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "A embarcação compartilhada não está transmitindo agora — mostrando a última posição conhecida",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "A embarcação compartilhada não está transmitindo agora — mostrando onde estava às {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "O satélite compartilhado não está transmitindo agora — mostrando a última posição conhecida",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "O satélite compartilhado não está transmitindo agora — mostrando onde estava às {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID da embarcação: {id}"
|
||||
}
|
||||
@@ -425,8 +425,6 @@
|
||||
"Link copied!": "Ссылка скопирована!",
|
||||
"Link shared": "Ссылка отправлена",
|
||||
"This share link can't be opened": "Не удаётся открыть эту ссылку",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} сейчас не передаёт данные — показано последнее известное место ({time})",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} сейчас не передаёт данные — показано последнее известное место",
|
||||
"Shared vessel is unavailable": "Судно по ссылке недоступно",
|
||||
"Copy failed": "Не удалось скопировать",
|
||||
"VISUAL PRESETS": "ПРЕСЕТЫ",
|
||||
@@ -3616,5 +3614,17 @@
|
||||
"Play speed": "Скорость воспроизведения",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё",
|
||||
"1 min/s": "1 мин/с",
|
||||
"Photo © {photographer}": "Фото © {photographer}"
|
||||
"Photo © {photographer}": "Фото © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Общий рейс сейчас не передаёт данные — показано последнее известное место",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Общий рейс сейчас не передаёт данные — показано место в {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Общий военный рейс сейчас не передаёт данные — показано последнее известное место",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Общий военный рейс сейчас не передаёт данные — показано место в {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "Общее судно сейчас не передаёт данные — показано последнее известное место",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "Общее судно сейчас не передаёт данные — показано место в {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Общий спутник сейчас не передаёт данные — показано последнее известное место",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Общий спутник сейчас не передаёт данные — показано место в {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID судна: {id}"
|
||||
}
|
||||
@@ -2325,8 +2325,6 @@
|
||||
"Shared {subject} follow expired": "Посилання на стеження застаріло: {subject}",
|
||||
"Shared {subject} could not be restored — feed unavailable": "Не вдалося відновити за посиланням: {subject} — потік даних недоступний",
|
||||
"Shared {subject} is unavailable": "Недоступно за посиланням: {subject}",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} зараз не передає даних — показано останнє відоме місце",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} зараз не передає даних — показано останнє відоме місце ({time})",
|
||||
"This share link can't be opened": "Не вдається відкрити це посилання",
|
||||
"Shared vessel is unavailable": "Судно за посиланням недоступне",
|
||||
"ACQUIRING": "ОТРИМАННЯ",
|
||||
@@ -3616,5 +3614,17 @@
|
||||
"Play speed": "Швидкість відтворення",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все",
|
||||
"1 min/s": "1 хв/с",
|
||||
"Photo © {photographer}": "Фото © {photographer}"
|
||||
"Photo © {photographer}": "Фото © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Спільний рейс зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Спільний рейс зараз не передає даних — показано місце о {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Спільний військовий рейс зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Спільний військовий рейс зараз не передає даних — показано місце о {time}",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "Спільне судно зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "Спільне судно зараз не передає даних — показано місце о {time}",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Спільний супутник зараз не передає даних — показано останнє відоме місце",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Спільний супутник зараз не передає даних — показано місце о {time}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID судна: {id}"
|
||||
}
|
||||
@@ -1605,8 +1605,6 @@
|
||||
"Link copied!": "链接已复制!",
|
||||
"Link shared": "链接已分享",
|
||||
"This share link can't be opened": "无法打开此分享链接",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} 目前没有发送信号——显示其在 {time} 的位置",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} 目前没有发送信号——显示其最后出现的位置",
|
||||
"Shared vessel is unavailable": "分享的船只不可用",
|
||||
"Copy failed": "复制失败",
|
||||
"Return map to straight-down view": "将地图恢复为垂直俯视",
|
||||
@@ -3373,5 +3371,17 @@
|
||||
"Play speed": "播放速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部",
|
||||
"1 min/s": "1 分钟/秒",
|
||||
"Photo © {photographer}": "照片 © {photographer}"
|
||||
"Photo © {photographer}": "照片 © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前没有发送信号——显示其最后出现的位置",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前没有发送信号——显示其在 {time} 的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "共享的军用航班目前没有发送信号——显示其最后出现的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "共享的军用航班目前没有发送信号——显示其在 {time} 的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "共享的船舶目前没有发送信号——显示其最后出现的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "共享的船舶目前没有发送信号——显示其在 {time} 的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "共享的卫星目前没有发送信号——显示其最后出现的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "共享的卫星目前没有发送信号——显示其在 {time} 的位置",
|
||||
"ICAO: {id}": "ICAO:{id}",
|
||||
"NORAD: {id}": "NORAD:{id}",
|
||||
"MMSI: {id}": "MMSI:{id}",
|
||||
"Vessel UUID: {id}": "船舶 UUID:{id}"
|
||||
}
|
||||
@@ -2983,8 +2983,6 @@
|
||||
"Take the exit {direction}": "從出口{direction}駛出",
|
||||
"Link shared": "鏈接已分享",
|
||||
"This share link can't be opened": "無法打開此分享鏈接",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} 目前沒有發送信號——顯示其在 {time} 的位置",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} 目前沒有發送信號——顯示其最後出現的位置",
|
||||
"Shared vessel is unavailable": "分享的船隻不可用",
|
||||
"Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。",
|
||||
"Press Enter to try again.": "按 Enter 重試。",
|
||||
@@ -3373,5 +3371,17 @@
|
||||
"Play speed": "播放速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部",
|
||||
"1 min/s": "1 分鐘/秒",
|
||||
"Photo © {photographer}": "照片 © {photographer}"
|
||||
"Photo © {photographer}": "照片 © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前沒有發送信號——顯示其最後出現的位置",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前沒有發送信號——顯示其在 {time} 的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "共享的軍用航班目前沒有發送信號——顯示其最後出現的位置",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "共享的軍用航班目前沒有發送信號——顯示其在 {time} 的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "共享的船舶目前沒有發送信號——顯示其最後出現的位置",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "共享的船舶目前沒有發送信號——顯示其在 {time} 的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "共享的衛星目前沒有發送信號——顯示其最後出現的位置",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "共享的衛星目前沒有發送信號——顯示其在 {time} 的位置",
|
||||
"ICAO: {id}": "ICAO:{id}",
|
||||
"NORAD: {id}": "NORAD:{id}",
|
||||
"MMSI: {id}": "MMSI:{id}",
|
||||
"Vessel UUID: {id}": "船舶 UUID:{id}"
|
||||
}
|
||||
@@ -44,7 +44,10 @@
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{source} HTTP {status}",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"de": [
|
||||
"1090 MHz",
|
||||
@@ -238,7 +241,10 @@
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"es": [
|
||||
"1 min/s",
|
||||
@@ -395,7 +401,10 @@
|
||||
"Reptiles",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
"iNaturalist",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"fr": [
|
||||
"1 min/s",
|
||||
@@ -810,7 +819,10 @@
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist",
|
||||
"SATELLITE"
|
||||
"SATELLITE",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"ja": [
|
||||
"1090 MHz",
|
||||
@@ -873,7 +885,10 @@
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"pt": [
|
||||
"1 min/s",
|
||||
@@ -1031,7 +1046,10 @@
|
||||
"CC BY 4.0",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
"iNaturalist",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"ru": [
|
||||
"1900–1945",
|
||||
@@ -1064,7 +1082,10 @@
|
||||
"iNaturalist ↗",
|
||||
"{direction} · {elevation}",
|
||||
"{direction} {angle}°",
|
||||
"{division}, {country}"
|
||||
"{division}, {country}",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"uk": [
|
||||
"1900–1945",
|
||||
@@ -1092,7 +1113,10 @@
|
||||
"iNaturalist ↗",
|
||||
"{direction} · {elevation}",
|
||||
"{direction} {angle}°",
|
||||
"{division}, {country}"
|
||||
"{division}, {country}",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
@@ -1331,6 +1355,9 @@
|
||||
"TLE",
|
||||
"{value} KM",
|
||||
"{value} KM/S",
|
||||
"Ironbow"
|
||||
"Ironbow",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
]
|
||||
}
|
||||
@@ -150,7 +150,7 @@ export function createHistory({ state: layerState, services, parts, source }) {
|
||||
return (
|
||||
items.find(
|
||||
(item) =>
|
||||
String(item?.icao24 || item?.mmsi || item?.id) === String(subject.id),
|
||||
String(item?.icao24 || item?.id || item?.mmsi) === String(subject.id),
|
||||
) || subject
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export function createPanel({ state: layerState, parts }) {
|
||||
.slice(page, page + AWARENESS_PAGE_SIZE)
|
||||
.map((item, index) => {
|
||||
const label = formatAwarenessLabel(item);
|
||||
const targetId = item.icao24 || item.mmsi || item.id;
|
||||
const targetId = item.icao24 || item.id || item.mmsi;
|
||||
const distance = presentAwarenessDistance(item.distanceM);
|
||||
if (!targetId)
|
||||
return {
|
||||
|
||||
@@ -85,7 +85,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
|
||||
.filter(
|
||||
(item) =>
|
||||
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'mmsi'),
|
||||
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'id'),
|
||||
);
|
||||
const coverage = installationsState.stats?.coverage;
|
||||
const installationRadius =
|
||||
@@ -519,7 +519,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
return {
|
||||
layerId: record.layerId,
|
||||
id: record.properties?.mmsi || record.id,
|
||||
id: record.properties?.vesselId || record.id,
|
||||
label: record.label || record.id,
|
||||
memberNames: record.properties?.memberNames || [],
|
||||
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* What the layer drops when the world clock moves it to another time (its
|
||||
* source declares a timeline; see src/time/snapshotTimeline.js).
|
||||
*/
|
||||
export function createClockFollow({ flightState, parts }) {
|
||||
/**
|
||||
* Forget how every contact moved, before the snapshot at another time takes
|
||||
* the place of every contact: their fixes, courses, floors and the tracked
|
||||
* trail belong to the time the clock left.
|
||||
*/
|
||||
function resetMotion() {
|
||||
flightState.records.reset();
|
||||
flightState._positionHistory.clear();
|
||||
flightState._displayCourse.clear();
|
||||
flightState._groundSnap.clear();
|
||||
flightState._displayFloorState.clear();
|
||||
flightState._drReconcileValid = false;
|
||||
flightState._cachedDRFrame = -1;
|
||||
flightState._cachedDRPosition = null;
|
||||
parts.tracking._clearTrail();
|
||||
flightState._lastCamPoseSig = '';
|
||||
}
|
||||
|
||||
/** Drop every contact: the time has none to show, or the clock went live. */
|
||||
function clear() {
|
||||
if (flightState._trackedIcao)
|
||||
parts.tracking._clearTracking(false, { evicted: true });
|
||||
resetMotion();
|
||||
for (const [icao24, billboard] of flightState._billboards) {
|
||||
flightState._billboardCollection?.remove(billboard);
|
||||
parts.rendering._releaseModel(icao24);
|
||||
}
|
||||
flightState._billboards.clear();
|
||||
flightState._cullPositions.clear();
|
||||
flightState._detectionObjects.clear();
|
||||
flightState.feed._count = 0;
|
||||
flightState.feed._lastUpdate = null;
|
||||
}
|
||||
|
||||
return { resetMotion, clear };
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FlightRecords } from './records.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createClockFollow } from './clockFollow.js';
|
||||
import { displayDelayMs, followsClock, RENDER_DELAY_SEC } from './policy.js';
|
||||
|
||||
// Flights follow the world clock only while their source declares a timeline
|
||||
// (src/time/snapshotTimeline.js); these cover the layer's side of it.
|
||||
|
||||
const T = 1_800_000_000_000;
|
||||
const following = { timeline: { active: () => true } };
|
||||
const live = { timeline: { active: () => false } };
|
||||
|
||||
function records(now = () => T) {
|
||||
return new FlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
now,
|
||||
});
|
||||
}
|
||||
const view = () => ({
|
||||
viewerLatDeg: null,
|
||||
viewerLonDeg: null,
|
||||
trackedId: null,
|
||||
floorWarmPoints: [],
|
||||
});
|
||||
const observation = (fields = {}) => ({
|
||||
id: 'abc123',
|
||||
reference: 'abc123',
|
||||
longitude: -77,
|
||||
latitude: 39,
|
||||
callsign: 'TEST1',
|
||||
baroAltitudeM: 1000,
|
||||
onGround: false,
|
||||
speedMps: 120,
|
||||
courseDeg: 90,
|
||||
positionTimeMs: T - 5_000,
|
||||
...fields,
|
||||
});
|
||||
|
||||
test('contacts are drawn behind the live edge, and at the time following the clock', () => {
|
||||
assert.equal(displayDelayMs({ timeline: null }), RENDER_DELAY_SEC * 1000);
|
||||
assert.equal(displayDelayMs({}), RENDER_DELAY_SEC * 1000);
|
||||
assert.equal(displayDelayMs(live), RENDER_DELAY_SEC * 1000);
|
||||
assert.equal(displayDelayMs(following), 0);
|
||||
assert.equal(followsClock({}), false);
|
||||
assert.equal(followsClock(following), true);
|
||||
});
|
||||
|
||||
test('civil records judge absence and receipt at the layer clock', () => {
|
||||
let now = T;
|
||||
const store = records(() => now);
|
||||
const { meta } = store.receive(observation({ positionTimeMs: null }), view());
|
||||
assert.equal(meta.observedReceiptMs, T);
|
||||
assert.equal(meta.newestFixMs, T, 'an undated fix is the clock time');
|
||||
assert.equal(
|
||||
store.absence('abc123', { complete: true, tracked: true }),
|
||||
'stale',
|
||||
);
|
||||
assert.equal(
|
||||
store.absence('abc123', {
|
||||
complete: true,
|
||||
tracked: true,
|
||||
nowMs: T + 11 * 60_000,
|
||||
}),
|
||||
'remove',
|
||||
'a given time judges retention',
|
||||
);
|
||||
store.receive(observation(), view());
|
||||
now = T + 11 * 60_000;
|
||||
assert.equal(
|
||||
store.absence('abc123', { complete: true, tracked: true }),
|
||||
'remove',
|
||||
'the clock judges retention when no time is given',
|
||||
);
|
||||
});
|
||||
|
||||
test('a records reset forgets where airframes were and keeps what they are', () => {
|
||||
const store = records();
|
||||
const first = store.receive(observation(), view());
|
||||
first.meta.typeCode = 'B738';
|
||||
first.meta.registration = 'N123';
|
||||
first.meta.photo = { src: 'photo.jpg' };
|
||||
store.absence('abc123', { complete: true });
|
||||
store.beginSnapshot(T);
|
||||
store.reset();
|
||||
assert.equal(store.data.size, 0);
|
||||
assert.equal(store.missingPolls.size, 0);
|
||||
assert.equal(store.lastSnapshotMs, null);
|
||||
const again = store.receive(
|
||||
observation({ positionTimeMs: T - 600_000, callsign: 'OTHER' }),
|
||||
view(),
|
||||
);
|
||||
assert.equal(again.prevMeta, undefined, 'no motion carries across');
|
||||
assert.equal(again.groundFlipped, false);
|
||||
assert.equal(again.meta.typeCode, 'B738');
|
||||
assert.equal(again.meta.registration, 'N123');
|
||||
assert.equal(again.meta.photo.src, 'photo.jpg');
|
||||
assert.equal(again.meta.newestFixMs, T - 600_000, 'an earlier fix applies');
|
||||
store.forget('abc123');
|
||||
assert.equal(store.airframes.size, 0);
|
||||
});
|
||||
|
||||
function ingestion(source, { follows = false } = {}) {
|
||||
const feed = {
|
||||
_source: source,
|
||||
_activeUpdateControllers: new Set(),
|
||||
_trackingRefreshEpoch: 0,
|
||||
_lastSource: 'Fixture',
|
||||
_retryAt: 0,
|
||||
};
|
||||
const applied = [];
|
||||
const state = { follows };
|
||||
const { methods } = createIngestion({
|
||||
feed,
|
||||
getQuery: () => ({ latitude: 39 }),
|
||||
applySnapshot: (snapshot, _viewer, options) => {
|
||||
applied.push({ snapshot, options });
|
||||
return { count: 1, ids: new Set() };
|
||||
},
|
||||
setSourceLabel: () => {},
|
||||
applyPendingTrackingRestore: () => {},
|
||||
followsClock: () => state.follows,
|
||||
});
|
||||
return { feed, applied, state, methods };
|
||||
}
|
||||
|
||||
test('civil polls carry no time; a snapshot at a time carries it and replaces', async () => {
|
||||
const queries = [];
|
||||
const probe = ingestion({
|
||||
async getSnapshot(query) {
|
||||
queries.push(query);
|
||||
return { records: [], observedAtMs: T - 60_000, freshness: 'current' };
|
||||
},
|
||||
});
|
||||
await probe.methods.update(null);
|
||||
assert.deepEqual(queries[0], { latitude: 39 });
|
||||
assert.equal(probe.applied[0].options, undefined);
|
||||
assert.equal(await probe.methods.loadAt(T - 60_000, { replace: true }), true);
|
||||
assert.deepEqual(queries[1], { latitude: 39, atMs: T - 60_000 });
|
||||
assert.deepEqual(probe.applied[1].options, { replace: true });
|
||||
assert.equal(probe.feed._lastUpdate, T - 60_000);
|
||||
});
|
||||
|
||||
test('following the clock, civil polls stop and a poll already out is dropped', async () => {
|
||||
let release;
|
||||
const probe = ingestion({
|
||||
getSnapshot: () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
});
|
||||
const out = probe.methods.update(null);
|
||||
probe.state.follows = true;
|
||||
release({ records: [], observedAtMs: T, freshness: 'current' });
|
||||
await out;
|
||||
assert.equal(probe.applied.length, 0);
|
||||
await probe.methods.update(null);
|
||||
assert.equal(probe.applied.length, 0, 'no poll while following');
|
||||
});
|
||||
|
||||
test('a failed snapshot at a time reports it without holding the live backoff', async () => {
|
||||
const probe = ingestion({
|
||||
async getSnapshot() {
|
||||
throw Object.assign(new Error('nope'), { retryAfterMs: 60_000 });
|
||||
},
|
||||
});
|
||||
assert.equal(await probe.methods.loadAt(T), false);
|
||||
assert.equal(probe.feed._retryAt, 0);
|
||||
assert.equal(probe.feed._lastError, 'Live data unavailable');
|
||||
});
|
||||
|
||||
test('a jump forgets how contacts moved; clearing drops every contact', () => {
|
||||
const calls = [];
|
||||
const flightState = {
|
||||
records: records(),
|
||||
_positionHistory: new Map([['abc123', []]]),
|
||||
_displayCourse: new Map([['abc123', 90]]),
|
||||
_groundSnap: { clear: () => calls.push('ground') },
|
||||
_displayFloorState: new Map([['abc123', {}]]),
|
||||
_drReconcileValid: true,
|
||||
_cachedDRFrame: 7,
|
||||
_billboards: new Map([['abc123', { id: 'abc123' }]]),
|
||||
_billboardCollection: { remove: () => calls.push('remove') },
|
||||
_cullPositions: new Map([['abc123', null]]),
|
||||
_detectionObjects: new Map([['abc123', {}]]),
|
||||
_trackedIcao: 'abc123',
|
||||
feed: { _count: 1, _lastUpdate: T },
|
||||
};
|
||||
const parts = {
|
||||
tracking: {
|
||||
_clearTrail: () => calls.push('trail'),
|
||||
_clearTracking: (_skip, { evicted }) => {
|
||||
calls.push(`untrack:${evicted}`);
|
||||
flightState._trackedIcao = null;
|
||||
},
|
||||
},
|
||||
rendering: { _releaseModel: (id) => calls.push(`model:${id}`) },
|
||||
};
|
||||
const follow = createClockFollow({ flightState, parts });
|
||||
flightState.records.receive(observation(), view());
|
||||
follow.resetMotion();
|
||||
assert.equal(flightState.records.data.size, 0);
|
||||
assert.equal(flightState._positionHistory.size, 0);
|
||||
assert.equal(flightState._displayCourse.size, 0);
|
||||
assert.equal(flightState._displayFloorState.size, 0);
|
||||
assert.equal(flightState._drReconcileValid, false);
|
||||
assert.equal(flightState._billboards.size, 1, 'contacts stay until replaced');
|
||||
follow.clear();
|
||||
assert.equal(flightState._billboards.size, 0);
|
||||
assert.equal(flightState._detectionObjects.size, 0);
|
||||
assert.equal(flightState.feed._count, 0);
|
||||
assert.equal(flightState.feed._lastUpdate, null);
|
||||
assert.ok(calls.includes('untrack:true'));
|
||||
assert.ok(calls.includes('model:abc123'));
|
||||
});
|
||||
|
||||
test('a snapshot at a time the source drops for a newer one sets no error', async () => {
|
||||
const probe = ingestion({
|
||||
async getSnapshot() {
|
||||
throw new DOMException('Superseded', 'AbortError');
|
||||
},
|
||||
});
|
||||
await assert.rejects(probe.methods.loadAt(T), { name: 'AbortError' });
|
||||
assert.equal(probe.feed._lastError, undefined);
|
||||
assert.equal(probe.feed._backoff, undefined);
|
||||
assert.equal(probe.feed._retryAt, 0);
|
||||
});
|
||||
@@ -10,6 +10,9 @@ import { createLifecycle } from './lifecycle.js';
|
||||
import { createEvidence } from './evidence.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { createClockFollow } from './clockFollow.js';
|
||||
import { createSnapshotTimeline } from '../../time/snapshotTimeline.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
/** Compose one civil-flight layer with application-owned scene services. */
|
||||
export function createCivilFlightLayer({
|
||||
source,
|
||||
@@ -29,6 +32,7 @@ export function createCivilFlightLayer({
|
||||
parts.evidence = createEvidence(context);
|
||||
parts.testing = createTesting(context);
|
||||
parts.queries = createQueries(context);
|
||||
parts.clockFollow = createClockFollow(context);
|
||||
const applySnapshot = createFlightSnapshotRenderer({
|
||||
flightState,
|
||||
records: flightState.records,
|
||||
@@ -45,7 +49,11 @@ export function createCivilFlightLayer({
|
||||
feed: flightState.feed,
|
||||
getQuery: (viewer) =>
|
||||
parts.controller._flightQuery(viewer || flightState._viewer),
|
||||
applySnapshot,
|
||||
applySnapshot: (snapshot, viewer, options) => {
|
||||
if (options?.replace) parts.clockFollow.resetMotion();
|
||||
return applySnapshot(snapshot, viewer, options);
|
||||
},
|
||||
followsClock: () => flightState.timeline.active(),
|
||||
setSourceLabel: (source) => {
|
||||
layer.source = source;
|
||||
},
|
||||
@@ -53,6 +61,19 @@ export function createCivilFlightLayer({
|
||||
parts.tracking._applyPendingTrackingRestore(),
|
||||
});
|
||||
|
||||
// Contacts follow the world clock while the source declares a timeline.
|
||||
flightState.timeline = createSnapshotTimeline({
|
||||
id: 'flights',
|
||||
clock: worldClock,
|
||||
source: () => flightState.feed._source,
|
||||
load: (atMs, { signal, replace }) =>
|
||||
parts.ingestion.methods.loadAt(atMs, { signal, replace }),
|
||||
clear: () => parts.clockFollow.clear(),
|
||||
resumeLive: () => {
|
||||
layer.update(flightState._viewer).catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
Object.assign(
|
||||
layer,
|
||||
parts.queries.methods,
|
||||
|
||||
+101
-67
@@ -11,85 +11,119 @@ export function createIngestion({
|
||||
applySnapshot,
|
||||
setSourceLabel,
|
||||
applyPendingTrackingRestore,
|
||||
followsClock = () => false,
|
||||
}) {
|
||||
const methods = {
|
||||
async update(viewer, { signal = null } = {}) {
|
||||
const nowMs = Date.now();
|
||||
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
|
||||
// Following the world clock, the layer shows the snapshots loadAt
|
||||
// applies; polling the live feed would put the live edge among them.
|
||||
if (followsClock()) return;
|
||||
await acquire(viewer, { signal });
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply the source's snapshot at world time `atMs` (its timeline says it
|
||||
* can answer one), in place of every contact when `replace`. Resolves
|
||||
* whether it was applied.
|
||||
* @param {number} atMs
|
||||
* @param {{signal?: AbortSignal|null, replace?: boolean, viewer?: object}} [options]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async loadAt(atMs, { signal = null, replace = false, viewer = null } = {}) {
|
||||
// Whatever polls or loads for another time no longer applies.
|
||||
if (replace)
|
||||
for (const controller of feed._activeUpdateControllers)
|
||||
controller.abort();
|
||||
return acquire(viewer, { signal, atMs, replace });
|
||||
},
|
||||
};
|
||||
|
||||
async function acquire(viewer, { signal = null, atMs, replace = false }) {
|
||||
const nowMs = Date.now();
|
||||
const timed = atMs !== undefined;
|
||||
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'source-unavailable',
|
||||
ids: new Set(),
|
||||
source: feed._lastSource,
|
||||
coverage: feed._lastCoverage,
|
||||
};
|
||||
if (!timed && feed._retryAt && nowMs < feed._retryAt) {
|
||||
feed._backoff = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceController = new AbortController();
|
||||
feed._activeUpdateControllers.add(resourceController);
|
||||
const updateSignal = signal
|
||||
? AbortSignal.any([signal, resourceController.signal])
|
||||
: resourceController.signal;
|
||||
try {
|
||||
updateSignal.throwIfAborted();
|
||||
const query = timed ? { ...getQuery(viewer), atMs } : getQuery(viewer);
|
||||
const snapshot = await feed._source.getSnapshot(query, {
|
||||
signal: updateSignal,
|
||||
});
|
||||
updateSignal.throwIfAborted();
|
||||
// The clock left the live edge while this poll was out.
|
||||
if (!timed && followsClock()) return;
|
||||
feed._lastStatus = snapshot.status ?? 200;
|
||||
const sourceEpochMs = snapshot.observedAtMs;
|
||||
const sourceAgeMs = snapshot.ageMs;
|
||||
const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
|
||||
feed._backoff = sourceStale;
|
||||
feed._retryAt = 0;
|
||||
// i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js)
|
||||
feed._lastError = sourceStale
|
||||
? sourceAgeMs == null
|
||||
? msg('Source snapshot time unavailable')
|
||||
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
|
||||
: null;
|
||||
feed._lastSource = snapshot.source;
|
||||
feed._lastCoverage = snapshot.coverage;
|
||||
setSourceLabel(feed._lastSource);
|
||||
const accepted = timed
|
||||
? applySnapshot(snapshot, viewer, { replace })
|
||||
: applySnapshot(snapshot, viewer);
|
||||
feed._count = accepted.count;
|
||||
// Freshness belongs to the source snapshot, not the moment this browser
|
||||
// received a cached 200 response.
|
||||
feed._lastUpdate = sourceEpochMs;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'source-unavailable',
|
||||
ids: new Set(),
|
||||
status: 'accepted',
|
||||
ids: accepted.ids,
|
||||
source: feed._lastSource,
|
||||
coverage: feed._lastCoverage,
|
||||
};
|
||||
if (feed._retryAt && nowMs < feed._retryAt) {
|
||||
feed._backoff = true;
|
||||
return;
|
||||
console.log(`[Data:Flights] Updated: ${feed._count} aircraft`);
|
||||
applyPendingTrackingRestore();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Flights update aborted', 'AbortError');
|
||||
}
|
||||
|
||||
const resourceController = new AbortController();
|
||||
feed._activeUpdateControllers.add(resourceController);
|
||||
const updateSignal = signal
|
||||
? AbortSignal.any([signal, resourceController.signal])
|
||||
: resourceController.signal;
|
||||
try {
|
||||
updateSignal.throwIfAborted();
|
||||
const snapshot = await feed._source.getSnapshot(getQuery(viewer), {
|
||||
signal: updateSignal,
|
||||
});
|
||||
updateSignal.throwIfAborted();
|
||||
feed._lastStatus = snapshot.status ?? 200;
|
||||
const sourceEpochMs = snapshot.observedAtMs;
|
||||
const sourceAgeMs = snapshot.ageMs;
|
||||
const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
|
||||
feed._backoff = sourceStale;
|
||||
feed._retryAt = 0;
|
||||
// i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js)
|
||||
feed._lastError = sourceStale
|
||||
? sourceAgeMs == null
|
||||
? msg('Source snapshot time unavailable')
|
||||
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
|
||||
: null;
|
||||
feed._lastSource = snapshot.source;
|
||||
feed._lastCoverage = snapshot.coverage;
|
||||
setSourceLabel(feed._lastSource);
|
||||
const accepted = applySnapshot(snapshot, viewer);
|
||||
feed._count = accepted.count;
|
||||
// Freshness belongs to the source snapshot, not the moment this browser
|
||||
// received a cached 200 response.
|
||||
feed._lastUpdate = sourceEpochMs;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'accepted',
|
||||
ids: accepted.ids,
|
||||
source: feed._lastSource,
|
||||
coverage: feed._lastCoverage,
|
||||
};
|
||||
console.log(`[Data:Flights] Updated: ${feed._count} aircraft`);
|
||||
applyPendingTrackingRestore();
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Flights update aborted', 'AbortError');
|
||||
}
|
||||
console.warn('[Data:Flights] Fetch error:', e);
|
||||
feed._backoff = true;
|
||||
console.warn('[Data:Flights] Fetch error:', e);
|
||||
feed._backoff = true;
|
||||
// A snapshot at a time is asked again by the clock's follower.
|
||||
if (!timed)
|
||||
feed._retryAt =
|
||||
Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
|
||||
feed._lastStatus = e?.status ?? null;
|
||||
if (e?.source) {
|
||||
feed._lastSource = e.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
}
|
||||
feed._lastError =
|
||||
e?.name === 'LiveSourceError'
|
||||
? e.message
|
||||
: msg('Live data unavailable');
|
||||
} finally {
|
||||
feed._activeUpdateControllers.delete(resourceController);
|
||||
feed._lastStatus = e?.status ?? null;
|
||||
if (e?.source) {
|
||||
feed._lastSource = e.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
}
|
||||
},
|
||||
};
|
||||
feed._lastError =
|
||||
e?.name === 'LiveSourceError'
|
||||
? e.message
|
||||
: msg('Live data unavailable');
|
||||
return false;
|
||||
} finally {
|
||||
feed._activeUpdateControllers.delete(resourceController);
|
||||
}
|
||||
}
|
||||
|
||||
return { methods };
|
||||
}
|
||||
|
||||
@@ -190,6 +190,7 @@ export function createLifecycle({
|
||||
);
|
||||
}
|
||||
restoreSpriteOrderOnEnable('flights', viewer);
|
||||
flightState.timeline?.start();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -198,6 +199,7 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
disable(viewer) {
|
||||
flightState.timeline?.stop();
|
||||
parts.controller._abortActiveUpdates();
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (flightState._billboardCollection)
|
||||
@@ -240,6 +242,7 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
destroy(viewer) {
|
||||
flightState.timeline?.stop();
|
||||
flightState.lifetime.abort();
|
||||
flightState._enrichActive = 0;
|
||||
flightState._enrichLastDispatchMs = 0;
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
import {
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
RENDER_DELAY_SEC,
|
||||
TRACKED_MODEL_MAX_PX,
|
||||
TRACKED_MODEL_MIN_PX,
|
||||
TRACKED_BILLBOARD_SCALE_BY_DISTANCE,
|
||||
@@ -39,6 +38,8 @@ import {
|
||||
DISPLAY_CORRIDOR_LOOKAHEAD_SEC,
|
||||
DISPLAY_CORRIDOR_CELL_BUDGET,
|
||||
DISPLAY_CORRIDOR_FAIR_SHARE,
|
||||
displayDelayMs,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
|
||||
/** Half a coarse floor cell (0.001°), less a margin so rounding keeps a
|
||||
@@ -178,7 +179,7 @@ export function createMotion({
|
||||
sampledRenderTime ??
|
||||
Cesium.JulianDate.addSeconds(
|
||||
julianAt(flightState.now(), flightState._scratchRenderTime),
|
||||
-RENDER_DELAY_SEC,
|
||||
-displayDelayMs(flightState) / 1000,
|
||||
flightState._scratchRenderTime,
|
||||
);
|
||||
|
||||
@@ -320,7 +321,7 @@ export function createMotion({
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime();
|
||||
const elapsedSec =
|
||||
(flightState.now() - RENDER_DELAY_SEC * 1000 - newestMs) / 1000;
|
||||
(flightState.now() - displayDelayMs(flightState) - newestMs) / 1000;
|
||||
if (!(elapsedSec > 0)) return null;
|
||||
const { coastSec, holds } = _coastPlan(
|
||||
newest,
|
||||
@@ -415,7 +416,7 @@ export function createMotion({
|
||||
if (!history || history.length === 0) return true;
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
-displayDelayMs(flightState) / 1000,
|
||||
flightState._scratchWarmupTime,
|
||||
);
|
||||
return Cesium.JulianDate.lessThan(renderTime, history[0].time);
|
||||
@@ -499,9 +500,15 @@ export function createMotion({
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
// How far the contact may move between frames goes by the world's time,
|
||||
// which plays faster than the wall clock when following it at a rate.
|
||||
const worldMs = followsClock(flightState) ? flightState.now() : nowMs;
|
||||
const info = flightState.records.data.get(icao24);
|
||||
if (sameTrack) {
|
||||
const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000);
|
||||
const dtSec = Math.max(
|
||||
0.001,
|
||||
(worldMs - flightState._drPrevWorldMs) / 1000,
|
||||
);
|
||||
const speed = (info && info.velocity) || 0;
|
||||
// Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base.
|
||||
const plausible = speed * dtSec * 4 + 25;
|
||||
@@ -549,6 +556,7 @@ export function createMotion({
|
||||
Cesium.Cartesian3.clone(raw, flightState._drPrevRaw);
|
||||
Cesium.Cartesian3.clone(display, flightState._drPrevDisplay);
|
||||
flightState._drPrevMs = nowMs;
|
||||
flightState._drPrevWorldMs = worldMs;
|
||||
flightState._drReconcileValid = true;
|
||||
flightState._cachedDRPosition = display;
|
||||
const focusSizePx = _trackedFocusSizePx(
|
||||
|
||||
@@ -147,6 +147,15 @@ export const TRAIL_MAX_POINTS = 2000;
|
||||
|
||||
export const RENDER_DELAY_SEC = 30;
|
||||
|
||||
/** Whether the layer shows the world clock's time instead of the live feed:
|
||||
* its source declares a timeline and the clock is away from live. */
|
||||
export const followsClock = (state) => state.timeline?.active() === true;
|
||||
|
||||
/** How far behind the time contacts are drawn: RENDER_DELAY_SEC live, none
|
||||
* following the clock, whose snapshots are loaded ahead of the time. */
|
||||
export const displayDelayMs = (state) =>
|
||||
followsClock(state) ? 0 : RENDER_DELAY_SEC * 1000;
|
||||
|
||||
/** @constant {number} Polls an aircraft may miss before removal (transient OpenSky dropouts). */
|
||||
|
||||
// --- Landed-plane fast cull (owner field report 2026-07-02: "phantom" planes
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
LANDED_SPEED_MAX_MPS,
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
||||
|
||||
@@ -986,6 +987,8 @@ export function createQueries({
|
||||
retryInSec,
|
||||
source: flightState.feed._lastSource,
|
||||
coverage: flightState.feed._lastCoverage,
|
||||
// Following the world clock, the snapshot's age is told from its time.
|
||||
...(followsClock(flightState) && { atMs: flightState.now() }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,13 +16,22 @@ import {
|
||||
|
||||
/** Source-independent aircraft metadata and bounded missing-poll retention. */
|
||||
export class FlightRecords {
|
||||
constructor({ geoidHeight, cachedGroundFloor, floorAltitudeM }) {
|
||||
constructor({
|
||||
geoidHeight,
|
||||
cachedGroundFloor,
|
||||
floorAltitudeM,
|
||||
now = () => Date.now(),
|
||||
}) {
|
||||
this.services = {
|
||||
geoidHeight,
|
||||
cachedGroundFloor,
|
||||
floorAltitudeM,
|
||||
};
|
||||
/** The time records are judged at: the layer's clock. */
|
||||
this.now = now;
|
||||
this.data = new Map();
|
||||
// Facts about an airframe, not a moment, kept across a reset.
|
||||
this.airframes = new Map();
|
||||
this.missingPolls = new Map();
|
||||
// Contacts whose last poll re-sent a fix no newer than the one before.
|
||||
this.fixRepeated = new Set();
|
||||
@@ -90,6 +99,9 @@ export class FlightRecords {
|
||||
// regressing to the ICAO hex / a 0° (north) heading. Bounded by the
|
||||
// MISSING_POLL_LIMIT eviction below, which deletes the whole entry.
|
||||
const prevMeta = this.data.get(icao24);
|
||||
// The airframe's facts: this record's, or those kept across a reset.
|
||||
const airframe = prevMeta ?? this.airframes.get(icao24);
|
||||
this.airframes.delete(icao24);
|
||||
// Grounded planes with no baro reading sit at 0 m, not the 10 km
|
||||
// airborne default (a parked plane must never float).
|
||||
// NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky
|
||||
@@ -244,16 +256,16 @@ export class FlightRecords {
|
||||
// A route (and its airline) belongs to the callsign it was looked up for;
|
||||
// a new callsign drops it, so an old flight's route never shows under it.
|
||||
const routeHeld =
|
||||
Boolean(prevMeta?.routeCallsign) &&
|
||||
prevMeta.routeCallsign === nextCallsign.toUpperCase();
|
||||
Boolean(airframe?.routeCallsign) &&
|
||||
airframe.routeCallsign === nextCallsign.toUpperCase();
|
||||
const fixEpochMs =
|
||||
Number.isFinite(observation.positionTimeMs) &&
|
||||
observation.positionTimeMs > 0
|
||||
? observation.positionTimeMs
|
||||
: Date.now();
|
||||
: this.now();
|
||||
const meta = {
|
||||
sourceReference: observation.reference,
|
||||
observedReceiptMs: Date.now(),
|
||||
observedReceiptMs: this.now(),
|
||||
newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs),
|
||||
callsign: nextCallsign,
|
||||
altitude: alt,
|
||||
@@ -273,7 +285,7 @@ export class FlightRecords {
|
||||
category: cat,
|
||||
// An adsbdb-enriched type code outranks the coarse OpenSky category.
|
||||
klass: classifyAircraft({
|
||||
typeCode: prevMeta?.typeCode ?? null,
|
||||
typeCode: airframe?.typeCode ?? null,
|
||||
category: cat,
|
||||
}),
|
||||
turnRateDps: prevMeta?.turnRateDps || 0,
|
||||
@@ -308,14 +320,14 @@ export class FlightRecords {
|
||||
null,
|
||||
),
|
||||
// adsbdb enrichment — written by the enrichment callbacks, carried across polls:
|
||||
typeCode: prevMeta?.typeCode ?? null,
|
||||
typeName: prevMeta?.typeName ?? null,
|
||||
registration: prevMeta?.registration ?? null,
|
||||
airline: routeHeld ? (prevMeta.airline ?? null) : null,
|
||||
route: routeHeld ? (prevMeta.route ?? null) : null,
|
||||
routeCallsign: routeHeld ? prevMeta.routeCallsign : null,
|
||||
typeCode: airframe?.typeCode ?? null,
|
||||
typeName: airframe?.typeName ?? null,
|
||||
registration: airframe?.registration ?? null,
|
||||
airline: routeHeld ? (airframe.airline ?? null) : null,
|
||||
route: routeHeld ? (airframe.route ?? null) : null,
|
||||
routeCallsign: routeHeld ? airframe.routeCallsign : null,
|
||||
// Planespotters photo of the tracked airframe (Core asks it directly).
|
||||
photo: prevMeta?.photo ?? null,
|
||||
photo: airframe?.photo ?? null,
|
||||
// The RAW poll fix lat/lon (this tick's OpenSky state-vector
|
||||
// coords, pre-dead-reckon) — kept distinct from the continuously
|
||||
// dead-reckoned billboard position for any consumer that needs the
|
||||
@@ -328,7 +340,10 @@ export class FlightRecords {
|
||||
return { icao24, prevMeta, meta, groundFlipped, fixEpochMs };
|
||||
}
|
||||
|
||||
absence(id, { complete, retainIds, likelyLanded, tracked = false }) {
|
||||
absence(
|
||||
id,
|
||||
{ complete, retainIds, likelyLanded, tracked = false, nowMs = this.now() },
|
||||
) {
|
||||
const misses = (this.missingPolls.get(id) || 0) + 1;
|
||||
// The tracked contact has missed an update: it is stale from the first
|
||||
// one, and kept (so the camera keeps its target) until ten minutes after
|
||||
@@ -337,7 +352,7 @@ export class FlightRecords {
|
||||
const meta = this.data.get(id);
|
||||
const fixMs = meta?.newestFixMs ?? meta?.observedReceiptMs;
|
||||
if (tracked && Number.isFinite(fixMs)) {
|
||||
if (Date.now() - fixMs < TRACKED_RETENTION_MS) {
|
||||
if (nowMs - fixMs < TRACKED_RETENTION_MS) {
|
||||
this.missingPolls.set(id, misses);
|
||||
return 'stale';
|
||||
}
|
||||
@@ -349,7 +364,7 @@ export class FlightRecords {
|
||||
// still takes the landed limit.
|
||||
if (
|
||||
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
|
||||
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
nowMs - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
)
|
||||
return 'retain';
|
||||
const limit = likelyLanded ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
|
||||
@@ -361,8 +376,33 @@ export class FlightRecords {
|
||||
return 'remove';
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget every observation, as when the world clock jumps to another time:
|
||||
* what an airframe is (its type, registration, route and photo) is kept for
|
||||
* when it is seen again; where it was and when is not.
|
||||
*/
|
||||
reset() {
|
||||
for (const [id, meta] of this.data) {
|
||||
this.airframes.set(id, {
|
||||
typeCode: meta.typeCode,
|
||||
typeName: meta.typeName,
|
||||
registration: meta.registration,
|
||||
airline: meta.airline,
|
||||
route: meta.route,
|
||||
routeCallsign: meta.routeCallsign,
|
||||
photo: meta.photo,
|
||||
});
|
||||
}
|
||||
this.data.clear();
|
||||
this.missingPolls.clear();
|
||||
this.fixRepeated.clear();
|
||||
this.lastSnapshotMs = null;
|
||||
this.snapshotAdvanced = true;
|
||||
}
|
||||
|
||||
forget(id) {
|
||||
this.data.delete(id);
|
||||
this.airframes.delete(id);
|
||||
this.missingPolls.delete(id);
|
||||
this.fixRepeated.delete(id);
|
||||
this.geoidNCache.delete(id);
|
||||
|
||||
@@ -33,13 +33,13 @@ import {
|
||||
TRACKED_MODEL_MIN_PX,
|
||||
TRACKED_MODEL_MAX_PX,
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
RENDER_DELAY_SEC,
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
ROTATION_REFRESH_MS,
|
||||
COURSE_MAX_DPS,
|
||||
HIDDEN_CONTACT_DR_TICKS,
|
||||
hiddenContactDrPhase,
|
||||
STALE_ALPHA,
|
||||
displayDelayMs,
|
||||
} from './policy.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||
|
||||
@@ -895,7 +895,7 @@ export function createRendering({
|
||||
// Every contact in this pass shares the same delayed display instant.
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
julianAt(flightState.now(), flightState._scratchFleetRenderTime),
|
||||
-RENDER_DELAY_SEC,
|
||||
-displayDelayMs(flightState) / 1000,
|
||||
flightState._scratchFleetRenderTime,
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
||||
import { followsClock } from './policy.js';
|
||||
|
||||
/** Apply reconciled aircraft records to Cesium resources and follow state. */
|
||||
export function createFlightSnapshotRenderer({
|
||||
@@ -25,7 +26,11 @@ export function createFlightSnapshotRenderer({
|
||||
const { refreshMilitaryRegistryIfStale, isMilitaryIcao } = militaryRegistry;
|
||||
const { warmGroundFloor, cachedGroundFloor } = groundFloor;
|
||||
const { sampleMeshFloorCells } = meshFloor;
|
||||
return function applySnapshot(snapshot, viewer) {
|
||||
/**
|
||||
* `replace`: the snapshot is the world at another time (the world clock
|
||||
* jumped), so it stands in for every contact: one it lacks is gone at once.
|
||||
*/
|
||||
return function applySnapshot(snapshot, viewer, { replace = false } = {}) {
|
||||
const currentIcaos = new Set();
|
||||
const acceptedSnapshotIcaos = new Set();
|
||||
// Field-test round 3 (2026-07-06, Austin fleet-underground): viewer
|
||||
@@ -153,7 +158,10 @@ export function createFlightSnapshotRenderer({
|
||||
});
|
||||
if (kinematicsChanged && !holds) {
|
||||
const synthetic = synthesizeForwardKinematicsFix(newest, {
|
||||
epochMs: Date.now(),
|
||||
// Following the clock, the snapshot's time is the moment it moves on from.
|
||||
epochMs: followsClock(flightState)
|
||||
? snapshot.observedAtMs
|
||||
: Date.now(),
|
||||
velocity: meta.velocity,
|
||||
track: meta.true_track,
|
||||
turnRateDps: meta.turnRateDps,
|
||||
@@ -263,12 +271,15 @@ export function createFlightSnapshotRenderer({
|
||||
// feed gap, and the full grace left phantom planes parked at airports.
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (currentIcaos.has(icao24)) continue;
|
||||
const absence = records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
tracked: icao24 === flightState._trackedIcao,
|
||||
});
|
||||
const absence = replace
|
||||
? 'remove'
|
||||
: records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
tracked: icao24 === flightState._trackedIcao,
|
||||
nowMs: flightState.now?.(),
|
||||
});
|
||||
if (absence === 'retain') continue;
|
||||
if (absence === 'stale') {
|
||||
if (icao24 === flightState._trackedIcao && flightState._trackedEntity) {
|
||||
@@ -302,6 +313,14 @@ export function createFlightSnapshotRenderer({
|
||||
flightState._displayFloorState.delete(icao24);
|
||||
}
|
||||
|
||||
// The tracked contact at the new time: its trail starts there.
|
||||
if (
|
||||
replace &&
|
||||
flightState._trackedIcao &&
|
||||
currentIcaos.has(flightState._trackedIcao)
|
||||
)
|
||||
tracking._startTrail(flightState._trackedIcao);
|
||||
|
||||
// Fresh courses arrived — force a rotation pass on the next fleet tick
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
|
||||
@@ -11,10 +11,14 @@ export function createFlightState({ source, services }) {
|
||||
flightState.records = new FlightRecords({
|
||||
...services.geoid,
|
||||
...services.groundFloor,
|
||||
now: () => flightState.now(),
|
||||
});
|
||||
flightState._cullPositions = new Map();
|
||||
/** The time contacts are drawn behind by the render delay, in epoch ms. */
|
||||
flightState.now = worldTimeFor('flights');
|
||||
/** Follows the world clock while the source declares a timeline (set by the
|
||||
* layer); see followsClock and displayDelayMs in policy.js. */
|
||||
flightState.timeline = null;
|
||||
|
||||
/** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs
|
||||
* vertex-baked to real-world METERS in the airplane.glb axis convention, so
|
||||
@@ -385,6 +389,9 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._drPrevMs = 0;
|
||||
|
||||
/** World time of that frame: the wall clock live, the cursor following the clock. */
|
||||
flightState._drPrevWorldMs = 0;
|
||||
|
||||
flightState._drReconcileValid = false;
|
||||
|
||||
/** @type {string|null} icao the reconciliation state currently belongs to */
|
||||
|
||||
@@ -21,10 +21,11 @@ import {
|
||||
TRACKED_MODEL_RETRY_BACKOFF_MS,
|
||||
TRAIL_MAX_POINTS,
|
||||
DR_CORRECTION_MS,
|
||||
RENDER_DELAY_SEC,
|
||||
TRAIL_COLOR,
|
||||
CYAN_TRANSPARENT,
|
||||
STALE_ALPHA,
|
||||
displayDelayMs,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
import {
|
||||
drawTrailSegments,
|
||||
@@ -376,7 +377,7 @@ export function createTracking({
|
||||
TRAIL_MAX_POINTS,
|
||||
{
|
||||
nullAltitude,
|
||||
displayMs: flightState.now() - RENDER_DELAY_SEC * 1000,
|
||||
displayMs: flightState.now() - displayDelayMs(flightState),
|
||||
settleMs: DR_CORRECTION_MS,
|
||||
},
|
||||
);
|
||||
@@ -392,7 +393,7 @@ export function createTracking({
|
||||
|
||||
function _trailTick() {
|
||||
if (!flightState._trackedIcao || !flightState._trail) return;
|
||||
const displayMs = flightState.now() - RENDER_DELAY_SEC * 1000;
|
||||
const displayMs = flightState.now() - displayDelayMs(flightState);
|
||||
let changed = false;
|
||||
if (
|
||||
flightState._trailFixes.length &&
|
||||
@@ -458,7 +459,7 @@ export function createTracking({
|
||||
flightState._trailFixes,
|
||||
trailBodyCount(
|
||||
flightState._trailFixes,
|
||||
flightState.now() - RENDER_DELAY_SEC * 1000,
|
||||
flightState.now() - displayDelayMs(flightState),
|
||||
),
|
||||
parts.motion._trackedModelCenterWorld(),
|
||||
parts.motion._trackedModelEnvelopeM(),
|
||||
@@ -587,15 +588,16 @@ export function createTracking({
|
||||
|
||||
async function _backfillTrail(icao24, token, oldestFixEpochSec) {
|
||||
let path = null;
|
||||
// Following the world clock, the track ends at the world's time.
|
||||
const atMs = followsClock(flightState) ? flightState.now() : undefined;
|
||||
try {
|
||||
const signal = AbortSignal.any([
|
||||
flightState.lifetime.signal,
|
||||
AbortSignal.timeout(8000),
|
||||
]);
|
||||
const track = await flightState.feed._source.getTrack?.(
|
||||
flightState.records.data.get(icao24)?.sourceReference ?? icao24,
|
||||
{
|
||||
signal: AbortSignal.any([
|
||||
flightState.lifetime.signal,
|
||||
AbortSignal.timeout(8000),
|
||||
]),
|
||||
},
|
||||
atMs === undefined ? { signal } : { signal, atMs },
|
||||
);
|
||||
path = track?.records ?? null;
|
||||
} catch {
|
||||
@@ -625,6 +627,7 @@ export function createTracking({
|
||||
} = waypoint;
|
||||
const time = observedAtMs / 1000;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
if (atMs !== undefined && !(observedAtMs <= atMs)) continue;
|
||||
if (!Number.isFinite(time) || time >= oldestFixEpochSec) continue;
|
||||
parsed.push({
|
||||
lat,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* What the layer drops when the world clock moves it to another time (its
|
||||
* source declares a timeline; see src/time/snapshotTimeline.js).
|
||||
*/
|
||||
export function createClockFollow({ flightState, parts }) {
|
||||
/**
|
||||
* Forget how every contact moved, before the snapshot at another time takes
|
||||
* the place of every contact: their fixes, courses and the tracked trail
|
||||
* belong to the time the clock left.
|
||||
*/
|
||||
function resetMotion() {
|
||||
flightState.records.reset();
|
||||
flightState._positionHistory.clear();
|
||||
flightState._displayCourse.clear();
|
||||
flightState._groundSnap.clear();
|
||||
flightState._drReconcileValid = false;
|
||||
flightState._cachedDRFrame = -1;
|
||||
flightState._cachedDRPosition = null;
|
||||
parts.tracking._clearTrail();
|
||||
flightState._lastCamPoseSig = '';
|
||||
}
|
||||
|
||||
/** Drop every contact: the time has none to show, or the clock went live. */
|
||||
function clear() {
|
||||
if (flightState._trackedIcao)
|
||||
parts.tracking._clearTracking(false, { evicted: true });
|
||||
resetMotion();
|
||||
for (const [icao24, billboard] of flightState._billboards) {
|
||||
flightState._billboardCollection?.remove(billboard);
|
||||
parts.rendering._releaseModel(icao24);
|
||||
}
|
||||
flightState._billboards.clear();
|
||||
flightState._cullPositions.clear();
|
||||
flightState._detectionObjects.clear();
|
||||
flightState.feed._count = 0;
|
||||
flightState.feed._lastUpdate = null;
|
||||
}
|
||||
|
||||
return { resetMotion, clear };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MilitaryFlightRecords } from './records.js';
|
||||
import { createIngestion, createMilitaryFeed } from './ingestion.js';
|
||||
import { displayDelayMs, RENDER_DELAY_SEC } from './policy.js';
|
||||
|
||||
// Military aircraft follow the world clock only while their source declares
|
||||
// a timeline (src/time/snapshotTimeline.js); these cover the layer's side.
|
||||
|
||||
const T = 1_800_000_000_000;
|
||||
|
||||
test('military contacts are drawn behind the live edge, and at the time following the clock', () => {
|
||||
assert.equal(displayDelayMs({ timeline: null }), RENDER_DELAY_SEC * 1000);
|
||||
assert.equal(displayDelayMs({ timeline: { active: () => false } }), 15_000);
|
||||
assert.equal(displayDelayMs({ timeline: { active: () => true } }), 0);
|
||||
});
|
||||
|
||||
test('military records judge absence at the layer clock and reset at a jump', () => {
|
||||
let now = T;
|
||||
const store = new MilitaryFlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
now: () => now,
|
||||
});
|
||||
const { meta } = store.receive(
|
||||
{
|
||||
id: 'ae0001',
|
||||
reference: 'ae0001',
|
||||
latitude: 39,
|
||||
longitude: -77,
|
||||
baroAltitudeM: 3000,
|
||||
onGround: false,
|
||||
positionTimeMs: T - 1_000,
|
||||
},
|
||||
{ observedAtMs: T, floorWarmPoints: [] },
|
||||
);
|
||||
assert.equal(meta.observedReceiptMs, T);
|
||||
assert.equal(
|
||||
store.absence('ae0001', { complete: true, tracked: true }),
|
||||
'stale',
|
||||
);
|
||||
now = T + 11 * 60_000;
|
||||
assert.equal(
|
||||
store.absence('ae0001', { complete: true, tracked: true }),
|
||||
'remove',
|
||||
);
|
||||
store.receive(
|
||||
{ id: 'ae0001', latitude: 39, longitude: -77, positionTimeMs: T },
|
||||
{ observedAtMs: T, floorWarmPoints: [] },
|
||||
);
|
||||
store.absence('ae0001', { complete: true });
|
||||
store.reset();
|
||||
assert.equal(store.data.size, 0);
|
||||
assert.equal(store.missingPolls.size, 0);
|
||||
assert.equal(store.lastSnapshotMs, null);
|
||||
});
|
||||
|
||||
test('military polls carry no time; a snapshot at a time carries it, and polls stop following', async () => {
|
||||
const queries = [];
|
||||
const applied = [];
|
||||
let follows = false;
|
||||
const feed = createMilitaryFeed({
|
||||
async getSnapshot(query) {
|
||||
queries.push(query);
|
||||
return { records: [], observedAtMs: T, freshness: 'current' };
|
||||
},
|
||||
});
|
||||
const { methods } = createIngestion({
|
||||
feed,
|
||||
applySnapshot: (_snapshot, _viewer, options) => {
|
||||
applied.push(options);
|
||||
return { count: 0, ids: new Set() };
|
||||
},
|
||||
setSourceLabel: () => {},
|
||||
applyPendingTrackingRestore: () => {},
|
||||
followsClock: () => follows,
|
||||
});
|
||||
await methods.update(null);
|
||||
assert.deepEqual(queries, [{}]);
|
||||
assert.deepEqual(applied, [undefined]);
|
||||
follows = true;
|
||||
await methods.update(null);
|
||||
assert.equal(queries.length, 1, 'no poll while following');
|
||||
assert.equal(await methods.loadAt(T, { replace: true }), true);
|
||||
assert.deepEqual(queries[1], { atMs: T });
|
||||
assert.deepEqual(applied[1], { replace: true });
|
||||
});
|
||||
@@ -8,6 +8,9 @@ import { createIngestion } from './ingestion.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { createClockFollow } from './clockFollow.js';
|
||||
import { createSnapshotTimeline } from '../../time/snapshotTimeline.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
/** Compose one military-flight layer with application-owned scene services. */
|
||||
export function createMilitaryFlightLayer({
|
||||
source,
|
||||
@@ -25,6 +28,7 @@ export function createMilitaryFlightLayer({
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
parts.testing = createTesting(context);
|
||||
parts.queries = createQueries(context);
|
||||
parts.clockFollow = createClockFollow(context);
|
||||
const applySnapshot = createMilitarySnapshotRenderer({
|
||||
flightState,
|
||||
records: flightState.records,
|
||||
@@ -38,7 +42,11 @@ export function createMilitaryFlightLayer({
|
||||
});
|
||||
parts.ingestion = createIngestion({
|
||||
feed: flightState.feed,
|
||||
applySnapshot,
|
||||
applySnapshot: (snapshot, viewer, options) => {
|
||||
if (options?.replace) parts.clockFollow.resetMotion();
|
||||
return applySnapshot(snapshot, viewer, options);
|
||||
},
|
||||
followsClock: () => flightState.timeline.active(),
|
||||
setSourceLabel: (source) => {
|
||||
layer.source = source;
|
||||
},
|
||||
@@ -46,6 +54,19 @@ export function createMilitaryFlightLayer({
|
||||
parts.tracking._applyPendingTrackingRestore(),
|
||||
});
|
||||
|
||||
// Contacts follow the world clock while the source declares a timeline.
|
||||
flightState.timeline = createSnapshotTimeline({
|
||||
id: 'military',
|
||||
clock: worldClock,
|
||||
source: () => flightState.feed._source,
|
||||
load: (atMs, { signal, replace }) =>
|
||||
parts.ingestion.methods.loadAt(atMs, { signal, replace }),
|
||||
clear: () => parts.clockFollow.clear(),
|
||||
resumeLive: () => {
|
||||
layer.update(flightState._viewer).catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
Object.assign(
|
||||
layer,
|
||||
parts.queries.methods,
|
||||
|
||||
@@ -10,78 +10,110 @@ export function createIngestion({
|
||||
applySnapshot,
|
||||
setSourceLabel,
|
||||
applyPendingTrackingRestore,
|
||||
followsClock = () => false,
|
||||
}) {
|
||||
const methods = {
|
||||
async update(viewer, { signal = null } = {}) {
|
||||
const nowMs = Date.now();
|
||||
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'source-unavailable',
|
||||
ids: new Set(),
|
||||
source: feed._lastSource,
|
||||
};
|
||||
if (feed._retryAt && nowMs < feed._retryAt) {
|
||||
feed._backoff = true;
|
||||
return;
|
||||
}
|
||||
// Following the world clock, the layer shows the snapshots loadAt
|
||||
// applies; polling the live feed would put the live edge among them.
|
||||
if (followsClock()) return;
|
||||
await acquire(viewer, { signal });
|
||||
},
|
||||
|
||||
const resourceController = new AbortController();
|
||||
feed._activeUpdateControllers.add(resourceController);
|
||||
const updateSignal = signal
|
||||
? AbortSignal.any([signal, resourceController.signal])
|
||||
: resourceController.signal;
|
||||
try {
|
||||
updateSignal.throwIfAborted();
|
||||
const snapshot = await feed._source.getSnapshot(
|
||||
{},
|
||||
{ signal: updateSignal },
|
||||
);
|
||||
updateSignal.throwIfAborted();
|
||||
feed._lastStatus = snapshot.status ?? 200;
|
||||
feed._lastSource = snapshot.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
feed._backoff = snapshot.stale || snapshot.freshness === 'unknown';
|
||||
feed._retryAt = 0;
|
||||
feed._lastError =
|
||||
snapshot.reason ||
|
||||
(snapshot.freshness === 'unknown'
|
||||
? msg('Source snapshot time unavailable')
|
||||
: null);
|
||||
const accepted = applySnapshot(snapshot, viewer);
|
||||
feed._count = accepted.count;
|
||||
feed._lastUpdate = snapshot.observedAtMs;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'accepted',
|
||||
ids: accepted.ids,
|
||||
source: feed._lastSource,
|
||||
};
|
||||
console.log(`[Data:Military] Updated: ${feed._count} aircraft`);
|
||||
applyPendingTrackingRestore();
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Military update aborted', 'AbortError');
|
||||
}
|
||||
console.warn('[Data:Military] Fetch error:', e);
|
||||
feed._backoff = true;
|
||||
feed._retryAt =
|
||||
Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
|
||||
feed._lastStatus = e?.status ?? null;
|
||||
if (e?.source) {
|
||||
feed._lastSource = e.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
}
|
||||
feed._lastError =
|
||||
e?.name === 'LiveSourceError'
|
||||
? e.message
|
||||
: msg('Live data unavailable');
|
||||
} finally {
|
||||
feed._activeUpdateControllers.delete(resourceController);
|
||||
}
|
||||
/**
|
||||
* Apply the source's snapshot at world time `atMs` (its timeline says it
|
||||
* can answer one), in place of every contact when `replace`. Resolves
|
||||
* whether it was applied.
|
||||
* @param {number} atMs
|
||||
* @param {{signal?: AbortSignal|null, replace?: boolean, viewer?: object}} [options]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async loadAt(atMs, { signal = null, replace = false, viewer = null } = {}) {
|
||||
// Whatever polls or loads for another time no longer applies.
|
||||
if (replace)
|
||||
for (const controller of feed._activeUpdateControllers)
|
||||
controller.abort();
|
||||
return acquire(viewer, { signal, atMs, replace });
|
||||
},
|
||||
};
|
||||
|
||||
async function acquire(viewer, { signal = null, atMs, replace = false }) {
|
||||
const nowMs = Date.now();
|
||||
const timed = atMs !== undefined;
|
||||
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'source-unavailable',
|
||||
ids: new Set(),
|
||||
source: feed._lastSource,
|
||||
};
|
||||
if (!timed && feed._retryAt && nowMs < feed._retryAt) {
|
||||
feed._backoff = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceController = new AbortController();
|
||||
feed._activeUpdateControllers.add(resourceController);
|
||||
const updateSignal = signal
|
||||
? AbortSignal.any([signal, resourceController.signal])
|
||||
: resourceController.signal;
|
||||
try {
|
||||
updateSignal.throwIfAborted();
|
||||
const snapshot = await feed._source.getSnapshot(timed ? { atMs } : {}, {
|
||||
signal: updateSignal,
|
||||
});
|
||||
updateSignal.throwIfAborted();
|
||||
// The clock left the live edge while this poll was out.
|
||||
if (!timed && followsClock()) return;
|
||||
feed._lastStatus = snapshot.status ?? 200;
|
||||
feed._lastSource = snapshot.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
feed._backoff = snapshot.stale || snapshot.freshness === 'unknown';
|
||||
feed._retryAt = 0;
|
||||
feed._lastError =
|
||||
snapshot.reason ||
|
||||
(snapshot.freshness === 'unknown'
|
||||
? msg('Source snapshot time unavailable')
|
||||
: null);
|
||||
const accepted = timed
|
||||
? applySnapshot(snapshot, viewer, { replace })
|
||||
: applySnapshot(snapshot, viewer);
|
||||
feed._count = accepted.count;
|
||||
feed._lastUpdate = snapshot.observedAtMs;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'accepted',
|
||||
ids: accepted.ids,
|
||||
source: feed._lastSource,
|
||||
};
|
||||
console.log(`[Data:Military] Updated: ${feed._count} aircraft`);
|
||||
applyPendingTrackingRestore();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Military update aborted', 'AbortError');
|
||||
}
|
||||
console.warn('[Data:Military] Fetch error:', e);
|
||||
feed._backoff = true;
|
||||
// A snapshot at a time is asked again by the clock's follower.
|
||||
if (!timed)
|
||||
feed._retryAt =
|
||||
Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
|
||||
feed._lastStatus = e?.status ?? null;
|
||||
if (e?.source) {
|
||||
feed._lastSource = e.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
}
|
||||
feed._lastError =
|
||||
e?.name === 'LiveSourceError'
|
||||
? e.message
|
||||
: msg('Live data unavailable');
|
||||
return false;
|
||||
} finally {
|
||||
feed._activeUpdateControllers.delete(resourceController);
|
||||
}
|
||||
}
|
||||
|
||||
return { methods };
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@ export function createLifecycle({
|
||||
);
|
||||
}
|
||||
restoreSpriteOrder(viewer);
|
||||
flightState.timeline?.start();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -175,6 +176,7 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
||||
*/
|
||||
disable(viewer) {
|
||||
flightState.timeline?.stop();
|
||||
parts.controller._abortActiveUpdates();
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (flightState._billboardCollection)
|
||||
@@ -219,6 +221,7 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
||||
*/
|
||||
destroy(viewer) {
|
||||
flightState.timeline?.stop();
|
||||
flightState.lifetime.abort();
|
||||
parts.controller._abortActiveUpdates();
|
||||
releaseContinuousRender('military'); // direct-destroy path (perf wave 2 fix)
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
modelAnchorWorld,
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
import {
|
||||
RENDER_DELAY_SEC,
|
||||
displayDelayMs,
|
||||
followsClock,
|
||||
TRACKED_MODEL_MAX_PX,
|
||||
TRACKED_MODEL_MIN_PX,
|
||||
BILLBOARD_SCALE,
|
||||
@@ -73,7 +74,7 @@ export function createMotion({
|
||||
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
-displayDelayMs(flightState) / 1000,
|
||||
flightState._scratchRenderTime,
|
||||
);
|
||||
|
||||
@@ -211,7 +212,7 @@ export function createMotion({
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime();
|
||||
const elapsedSec =
|
||||
(flightState.now() - RENDER_DELAY_SEC * 1000 - newestMs) / 1000;
|
||||
(flightState.now() - displayDelayMs(flightState) - newestMs) / 1000;
|
||||
if (!(elapsedSec > 0)) return null;
|
||||
const { coastSec, holds } = _coastPlan(
|
||||
newest,
|
||||
@@ -285,7 +286,7 @@ export function createMotion({
|
||||
if (!history || history.length === 0) return true;
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
-displayDelayMs(flightState) / 1000,
|
||||
flightState._scratchWarmupTime,
|
||||
);
|
||||
return Cesium.JulianDate.lessThan(renderTime, history[0].time);
|
||||
@@ -370,9 +371,15 @@ export function createMotion({
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
// How far the contact may move between frames goes by the world's time,
|
||||
// which plays faster than the wall clock when following it at a rate.
|
||||
const worldMs = followsClock(flightState) ? flightState.now() : nowMs;
|
||||
const info = flightState.records.data.get(icao24);
|
||||
if (sameTrack) {
|
||||
const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000);
|
||||
const dtSec = Math.max(
|
||||
0.001,
|
||||
(worldMs - flightState._drPrevWorldMs) / 1000,
|
||||
);
|
||||
const speed = (info && info.speedMps) || 0;
|
||||
// Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base.
|
||||
const plausible = speed * dtSec * 4 + 25;
|
||||
@@ -403,6 +410,7 @@ export function createMotion({
|
||||
Cesium.Cartesian3.clone(raw, flightState._drPrevRaw);
|
||||
Cesium.Cartesian3.clone(display, flightState._drPrevDisplay);
|
||||
flightState._drPrevMs = nowMs;
|
||||
flightState._drPrevWorldMs = worldMs;
|
||||
flightState._drReconcileValid = true;
|
||||
flightState._cachedDRPosition = display;
|
||||
const focusSizePx = _trackedFocusSizePx(
|
||||
|
||||
@@ -186,6 +186,15 @@ export const TRAIL_MAX_POINTS = 2000;
|
||||
|
||||
export const RENDER_DELAY_SEC = 15;
|
||||
|
||||
/** Whether the layer shows the world clock's time instead of the live feed:
|
||||
* its source declares a timeline and the clock is away from live. */
|
||||
export const followsClock = (state) => state.timeline?.active() === true;
|
||||
|
||||
/** How far behind the time contacts are drawn: RENDER_DELAY_SEC live, none
|
||||
* following the clock, whose snapshots are loaded ahead of the time. */
|
||||
export const displayDelayMs = (state) =>
|
||||
followsClock(state) ? 0 : RENDER_DELAY_SEC * 1000;
|
||||
|
||||
/** @constant {number} Polls an aircraft may miss before removal (transient adsb.lol dropouts). */
|
||||
|
||||
// --- Landed-plane fast cull (mirror of flights.js; owner field report
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
contactMatchWins,
|
||||
CONTACT_MATCH_TIER,
|
||||
} from '../../data/contactMatch.js';
|
||||
import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js';
|
||||
import {
|
||||
LANDED_ALT_MAX_FT,
|
||||
LANDED_SPEED_MAX_MPS,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
||||
|
||||
export function createQueries({
|
||||
@@ -834,6 +838,8 @@ export function createQueries({
|
||||
retryInSec,
|
||||
source: flightState.feed._lastSource,
|
||||
fallback: false,
|
||||
// Following the world clock, the snapshot's age is told from its time.
|
||||
...(followsClock(flightState) && { atMs: flightState.now() }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,8 +11,15 @@ import {
|
||||
|
||||
/** Portable military aircraft metadata and bounded missing-poll retention. */
|
||||
export class MilitaryFlightRecords {
|
||||
constructor({ geoidHeight, cachedGroundFloor, floorAltitudeM }) {
|
||||
constructor({
|
||||
geoidHeight,
|
||||
cachedGroundFloor,
|
||||
floorAltitudeM,
|
||||
now = () => Date.now(),
|
||||
}) {
|
||||
this.services = { geoidHeight, cachedGroundFloor, floorAltitudeM };
|
||||
/** The time records are judged at: the layer's clock. */
|
||||
this.now = now;
|
||||
this.data = new Map();
|
||||
this.missingPolls = new Map();
|
||||
// Contacts whose last poll re-sent a fix no newer than the one before.
|
||||
@@ -170,7 +177,7 @@ export class MilitaryFlightRecords {
|
||||
const fixEpochMs = aircraft.positionTimeMs ?? observedAtMs;
|
||||
const meta = {
|
||||
sourceReference: aircraft.reference,
|
||||
observedReceiptMs: Date.now(),
|
||||
observedReceiptMs: this.now(),
|
||||
newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs),
|
||||
callsign: stickyText(callsign, prevMeta?.callsign),
|
||||
type: stickyType,
|
||||
@@ -230,7 +237,10 @@ export class MilitaryFlightRecords {
|
||||
fixEpochMs,
|
||||
};
|
||||
}
|
||||
absence(id, { complete, retainIds, likelyLanded, tracked = false }) {
|
||||
absence(
|
||||
id,
|
||||
{ complete, retainIds, likelyLanded, tracked = false, nowMs = this.now() },
|
||||
) {
|
||||
const misses = (this.missingPolls.get(id) || 0) + 1;
|
||||
// The tracked contact has missed an update: it is stale from the first
|
||||
// one, and kept (so the camera keeps its target) until ten minutes after
|
||||
@@ -239,7 +249,7 @@ export class MilitaryFlightRecords {
|
||||
const meta = this.data.get(id);
|
||||
const fixMs = meta?.newestFixMs ?? meta?.observedReceiptMs;
|
||||
if (tracked && Number.isFinite(fixMs)) {
|
||||
if (Date.now() - fixMs < TRACKED_RETENTION_MS) {
|
||||
if (nowMs - fixMs < TRACKED_RETENTION_MS) {
|
||||
this.missingPolls.set(id, misses);
|
||||
return 'stale';
|
||||
}
|
||||
@@ -251,7 +261,7 @@ export class MilitaryFlightRecords {
|
||||
// still takes the landed limit.
|
||||
if (
|
||||
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
|
||||
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
nowMs - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
)
|
||||
return 'retain';
|
||||
const limit = likelyLanded ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
|
||||
@@ -262,6 +272,14 @@ export class MilitaryFlightRecords {
|
||||
this.missingPolls.delete(id);
|
||||
return 'remove';
|
||||
}
|
||||
/** Forget every observation, as when the world clock jumps to another time. */
|
||||
reset() {
|
||||
this.data.clear();
|
||||
this.missingPolls.clear();
|
||||
this.fixRepeated.clear();
|
||||
this.lastSnapshotMs = null;
|
||||
this.snapshotAdvanced = true;
|
||||
}
|
||||
forget(id) {
|
||||
this.data.delete(id);
|
||||
this.missingPolls.delete(id);
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
||||
import { BILLBOARD_SCALE, GROUND_SCALE, TRACKED_ICON_COLOR } from './policy.js';
|
||||
import {
|
||||
BILLBOARD_SCALE,
|
||||
GROUND_SCALE,
|
||||
TRACKED_ICON_COLOR,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
|
||||
/** Apply military record changes to Cesium resources and existing follow operations. */
|
||||
export function createMilitarySnapshotRenderer({
|
||||
@@ -27,7 +32,11 @@ export function createMilitarySnapshotRenderer({
|
||||
groundFloor;
|
||||
const { sampleMeshFloorCells } = meshFloor;
|
||||
const { registerMilitaryIcaos } = militaryRegistry;
|
||||
return function applySnapshot(snapshot, viewer) {
|
||||
/**
|
||||
* `replace`: the snapshot is the world at another time (the world clock
|
||||
* jumped), so it stands in for every contact: one it lacks is gone at once.
|
||||
*/
|
||||
return function applySnapshot(snapshot, viewer, { replace = false } = {}) {
|
||||
const currentIcaos = new Set();
|
||||
const receiptNowMs = snapshot.observedAtMs;
|
||||
records.beginSnapshot(snapshot.observedAtMs);
|
||||
@@ -120,7 +129,10 @@ export function createMilitarySnapshotRenderer({
|
||||
});
|
||||
if (kinematicsChanged && !holds) {
|
||||
const synthetic = synthesizeForwardKinematicsFix(newest, {
|
||||
epochMs: Date.now(),
|
||||
// Following the clock, the snapshot's time is the moment it moves on from.
|
||||
epochMs: followsClock(flightState)
|
||||
? snapshot.observedAtMs
|
||||
: Date.now(),
|
||||
velocity: meta.speedMps,
|
||||
track: meta.track,
|
||||
turnRateDps: meta.turnRateDps,
|
||||
@@ -272,12 +284,15 @@ export function createMilitarySnapshotRenderer({
|
||||
// feed gap, and the full grace left phantom planes parked at airports.
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (currentIcaos.has(icao24)) continue;
|
||||
const absence = records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
tracked: icao24 === flightState._trackedIcao,
|
||||
});
|
||||
const absence = replace
|
||||
? 'remove'
|
||||
: records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
tracked: icao24 === flightState._trackedIcao,
|
||||
nowMs: flightState.now?.(),
|
||||
});
|
||||
if (absence === 'retain') continue;
|
||||
if (absence === 'stale') {
|
||||
if (icao24 === flightState._trackedIcao && flightState._trackedEntity) {
|
||||
@@ -310,6 +325,14 @@ export function createMilitarySnapshotRenderer({
|
||||
flightState._groundSnap.forget(icao24);
|
||||
}
|
||||
|
||||
// The tracked contact at the new time: its trail starts there.
|
||||
if (
|
||||
replace &&
|
||||
flightState._trackedIcao &&
|
||||
currentIcaos.has(flightState._trackedIcao)
|
||||
)
|
||||
tracking._startTrail(flightState._trackedIcao);
|
||||
|
||||
// Fresh courses arrived — force a rotation pass on the next fleet tick
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
|
||||
@@ -10,10 +10,14 @@ export function createFlightState({ source, services }) {
|
||||
flightState.records = new MilitaryFlightRecords({
|
||||
...services.geoid,
|
||||
...services.groundFloor,
|
||||
now: () => flightState.now(),
|
||||
});
|
||||
flightState._cullPositions = new Map();
|
||||
/** The time contacts are drawn behind by the render delay, in epoch ms. */
|
||||
flightState.now = worldTimeFor('military');
|
||||
/** Follows the world clock while the source declares a timeline (set by the
|
||||
* layer); see followsClock and displayDelayMs in policy.js. */
|
||||
flightState.timeline = null;
|
||||
|
||||
/** Per-class model spec for THIS layer (2026-08-16, owner playtest ask:
|
||||
* military contacts should read as their WEIGHT CLASS, always in this layer's
|
||||
@@ -321,6 +325,9 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._drPrevMs = 0;
|
||||
|
||||
/** World time of that frame: the wall clock live, the cursor following the clock. */
|
||||
flightState._drPrevWorldMs = 0;
|
||||
|
||||
flightState._drReconcileValid = false;
|
||||
|
||||
/** @type {string|null} icao the reconciliation state currently belongs to */
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
TRACKED_MODEL_RETRY_BACKOFF_MS,
|
||||
TRAIL_MAX_POINTS,
|
||||
DR_CORRECTION_MS,
|
||||
RENDER_DELAY_SEC,
|
||||
displayDelayMs,
|
||||
followsClock,
|
||||
TRAIL_COLOR,
|
||||
BILLBOARD_SCALE,
|
||||
GROUND_SCALE,
|
||||
@@ -574,7 +575,7 @@ export function createTracking({
|
||||
TRAIL_MAX_POINTS,
|
||||
{
|
||||
nullAltitude,
|
||||
displayMs: flightState.now() - RENDER_DELAY_SEC * 1000,
|
||||
displayMs: flightState.now() - displayDelayMs(flightState),
|
||||
settleMs: DR_CORRECTION_MS,
|
||||
},
|
||||
);
|
||||
@@ -590,7 +591,7 @@ export function createTracking({
|
||||
|
||||
function _trailTick() {
|
||||
if (!flightState._trackedIcao || !flightState._trail) return;
|
||||
const displayMs = flightState.now() - RENDER_DELAY_SEC * 1000;
|
||||
const displayMs = flightState.now() - displayDelayMs(flightState);
|
||||
let changed = false;
|
||||
if (
|
||||
flightState._trailFixes.length &&
|
||||
@@ -651,7 +652,7 @@ export function createTracking({
|
||||
flightState._trailFixes,
|
||||
trailBodyCount(
|
||||
flightState._trailFixes,
|
||||
flightState.now() - RENDER_DELAY_SEC * 1000,
|
||||
flightState.now() - displayDelayMs(flightState),
|
||||
),
|
||||
parts.motion._trackedModelCenterWorld(),
|
||||
parts.motion._trackedModelEnvelopeM(),
|
||||
@@ -789,15 +790,16 @@ export function createTracking({
|
||||
|
||||
async function _backfillTrail(icao24, token, oldestFixEpochSec) {
|
||||
let trace = null;
|
||||
// Following the world clock, the track ends at the world's time.
|
||||
const atMs = followsClock(flightState) ? flightState.now() : undefined;
|
||||
try {
|
||||
const signal = AbortSignal.any([
|
||||
flightState.lifetime.signal,
|
||||
AbortSignal.timeout(8000),
|
||||
]);
|
||||
const track = await flightState.feed._source.getTrack?.(
|
||||
flightState.records.data.get(icao24)?.sourceReference ?? icao24,
|
||||
{
|
||||
signal: AbortSignal.any([
|
||||
flightState.lifetime.signal,
|
||||
AbortSignal.timeout(8000),
|
||||
]),
|
||||
},
|
||||
atMs === undefined ? { signal } : { signal, atMs },
|
||||
);
|
||||
trace = track?.records ?? null;
|
||||
} catch {
|
||||
@@ -825,6 +827,7 @@ export function createTracking({
|
||||
lon = point.longitude;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
if (!Number.isFinite(t) || t >= oldestFixEpochSec) continue;
|
||||
if (atMs !== undefined && !(point.observedAtMs <= atMs)) continue;
|
||||
parsed.push({
|
||||
lat,
|
||||
lon,
|
||||
|
||||
@@ -60,7 +60,7 @@ export function createCards({
|
||||
parts.push(`${roundHeadingDegrees(direction)}°`);
|
||||
return {
|
||||
id: vesselOverlayEntryId(record),
|
||||
actionable: Boolean(record?.mmsi),
|
||||
actionable: Boolean(record?.id),
|
||||
position:
|
||||
components.rendering.getVisual(record).billboard?.position ||
|
||||
components.rendering.getVisual(record).position,
|
||||
@@ -101,7 +101,7 @@ export function createCards({
|
||||
);
|
||||
return {
|
||||
id: vesselOverlayEntryId(record),
|
||||
actionable: Boolean(record?.mmsi),
|
||||
actionable: Boolean(record?.id),
|
||||
position:
|
||||
components.rendering.getVisual(record).billboard?.position ||
|
||||
components.rendering.getVisual(record).position,
|
||||
@@ -114,11 +114,11 @@ export function createCards({
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable overlay identity for MMSI-keyed and source-retained unkeyed rows. */
|
||||
/** Stable overlay identity for keyed and source-retained unkeyed rows. */
|
||||
|
||||
function vesselOverlayEntryId(record) {
|
||||
const mmsi = String(record?.mmsi || '').trim();
|
||||
if (mmsi) return `vessel:${mmsi}`;
|
||||
const id = String(record?.id || '').trim();
|
||||
if (id) return `vessel:${id}`;
|
||||
const name = String(record?.name || 'VESSEL').trim() || 'VESSEL';
|
||||
const lat = Number.isFinite(record?.lat) ? record.lat.toFixed(5) : 'x';
|
||||
const lon = Number.isFinite(record?.lon) ? record.lon.toFixed(5) : 'x';
|
||||
@@ -161,7 +161,9 @@ export function createCards({
|
||||
|
||||
function displayVesselName(record) {
|
||||
const name = String(record.name || '').trim();
|
||||
if (name && name !== 'VESSEL' && name !== record.mmsi) return name;
|
||||
// A source may name a vessel by its id when it has no name.
|
||||
if (name && name !== 'VESSEL' && name !== record.mmsi && name !== record.id)
|
||||
return name;
|
||||
return record.mmsi ? `MMSI ${record.mmsi}` : t('VESSEL');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { VesselRecords } from './records.js';
|
||||
import { createIngestion, createVesselFeed } from './ingestion.js';
|
||||
|
||||
// Vessels follow the world clock only while their source declares a
|
||||
// timeline (src/time/snapshotTimeline.js); these cover the layer's side.
|
||||
|
||||
const row = (mmsi, fields = {}) => ({
|
||||
mmsi,
|
||||
lat: 51.93,
|
||||
lon: 4.05,
|
||||
name: mmsi,
|
||||
last_position_epoch: 1_000,
|
||||
...fields,
|
||||
});
|
||||
|
||||
function store() {
|
||||
const records = new VesselRecords({ now: () => 5_000_000 });
|
||||
const events = [];
|
||||
const effects = {
|
||||
add: (record) => events.push(['add', record.mmsi]),
|
||||
beforeUpdate: () => null,
|
||||
updated: (record) => events.push(['update', record.mmsi]),
|
||||
remove: (record, evicted) => events.push(['remove', record.mmsi, evicted]),
|
||||
removed: (mmsi) => events.push(['removed', mmsi]),
|
||||
staleSelected: (record) => events.push(['stale', record.mmsi]),
|
||||
};
|
||||
return {
|
||||
records,
|
||||
events,
|
||||
reconcile: (rows, options = {}) =>
|
||||
records.reconcile(rows, options, effects),
|
||||
};
|
||||
}
|
||||
|
||||
test('rows at another time stand in for every vessel, selected or not', () => {
|
||||
const probe = store();
|
||||
probe.reconcile([row('111'), row('222')]);
|
||||
const selectedRecord = probe.records.byId.get('222');
|
||||
probe.reconcile([row('111', { last_position_epoch: 900 })], {
|
||||
complete: false,
|
||||
selectedRecord,
|
||||
replace: true,
|
||||
});
|
||||
assert.deepEqual([...probe.records.byId.keys()], ['111']);
|
||||
assert.deepEqual(probe.events.slice(-3), [
|
||||
['update', '111'],
|
||||
['remove', '222', true],
|
||||
['removed', '222'],
|
||||
]);
|
||||
assert.equal(probe.records.byId.get('111').motion, null);
|
||||
});
|
||||
|
||||
test('following the clock, a vessel keeps where its fix moved from', () => {
|
||||
const probe = store();
|
||||
probe.reconcile([row('111')]);
|
||||
probe.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })], {
|
||||
follow: true,
|
||||
});
|
||||
const record = probe.records.byId.get('111');
|
||||
assert.deepEqual(record.motion, {
|
||||
fromLat: 51.93,
|
||||
fromLon: 4.05,
|
||||
fromMs: 1_000_000,
|
||||
toLat: 52,
|
||||
toLon: 4.05,
|
||||
toMs: 1_010_000,
|
||||
});
|
||||
probe.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })], {
|
||||
follow: true,
|
||||
});
|
||||
assert.equal(record.motion.toMs, 1_010_000, 'a repeated fix keeps its move');
|
||||
const live = store();
|
||||
live.reconcile([row('111')]);
|
||||
live.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })]);
|
||||
assert.equal(
|
||||
Object.hasOwn(live.records.byId.get('111'), 'motion'),
|
||||
false,
|
||||
'live records are as before',
|
||||
);
|
||||
});
|
||||
|
||||
function ingestion(source) {
|
||||
const feed = createVesselFeed();
|
||||
feed.enabled = true;
|
||||
feed.sessionId = 1;
|
||||
const applied = [];
|
||||
const state = { follows: false, reloads: 0, radiusKm: 50 };
|
||||
const methods = createIngestion({
|
||||
feed,
|
||||
readSource: () => source,
|
||||
readViewer: () => ({}),
|
||||
readArea: () => ({ lat: 1, lon: 2, radiusKm: state.radiusKm }),
|
||||
getRowLimit: () => 500,
|
||||
readCount: () => applied.at(-1)?.rows.length ?? 0,
|
||||
now: () => 9999,
|
||||
setSourceLabel: () => {},
|
||||
applyRows: (_viewer, rows, options) => applied.push({ rows, options }),
|
||||
classifySnapshot: (payload) => ({
|
||||
acceptedRows: payload.rows,
|
||||
acceptedRowCount: payload.rows.length,
|
||||
rawRowCount: payload.rows.length,
|
||||
transportStatus: payload.status,
|
||||
lastMessageAt: payload.lastMessageAt,
|
||||
error: null,
|
||||
}),
|
||||
isDefinitiveTransportFailure: () => false,
|
||||
isGraceEligibleTransport: () => false,
|
||||
markUnavailable: () => {},
|
||||
settleFirstConnect: () => {},
|
||||
followsClock: () => state.follows,
|
||||
reloadAtClock: () => {
|
||||
state.reloads += 1;
|
||||
},
|
||||
});
|
||||
return { feed, applied, state, ...methods };
|
||||
}
|
||||
|
||||
test('vessel polls carry no time; a snapshot at a time carries it and replaces', async () => {
|
||||
const queries = [];
|
||||
const probe = ingestion({
|
||||
async getSnapshot(query) {
|
||||
queries.push(query);
|
||||
return {
|
||||
records: [{ id: '111', latitude: 1, longitude: 2, observedAtMs: 5 }],
|
||||
observedAtMs: 5,
|
||||
freshness: 'current',
|
||||
complete: true,
|
||||
transportStatus: 'live',
|
||||
};
|
||||
},
|
||||
});
|
||||
await probe.methods.update();
|
||||
assert.equal(Object.hasOwn(queries[0], 'atMs'), false);
|
||||
assert.deepEqual(probe.applied[0].options, { complete: true });
|
||||
probe.state.follows = true;
|
||||
await probe.methods.update();
|
||||
assert.equal(queries.length, 1, 'no poll while following');
|
||||
assert.equal(await probe.loadAt(5_000, { replace: true }), true);
|
||||
assert.equal(queries[1].atMs, 5_000);
|
||||
assert.deepEqual(probe.applied[1].options, { complete: true, replace: true });
|
||||
assert.equal(probe.feed.lastUpdate, 5);
|
||||
// Following the clock, a moved view reloads the time shown.
|
||||
probe.state.radiusKm = 2_000;
|
||||
await probe.refreshIfMoved({});
|
||||
assert.equal(probe.state.reloads, 1);
|
||||
assert.equal(queries.length, 2);
|
||||
});
|
||||
|
||||
test('an empty vessel snapshot at another time shows no vessels', async () => {
|
||||
const probe = ingestion({
|
||||
async getSnapshot() {
|
||||
return { records: [], observedAtMs: 5, freshness: 'current' };
|
||||
},
|
||||
});
|
||||
probe.state.follows = true;
|
||||
await probe.loadAt(5_000, { replace: true });
|
||||
assert.deepEqual(probe.applied, [
|
||||
{ rows: [], options: { complete: true, replace: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a vessel snapshot at a time the source drops for a newer one is no failure', async () => {
|
||||
const probe = ingestion({
|
||||
async getSnapshot() {
|
||||
throw new DOMException('Superseded', 'AbortError');
|
||||
},
|
||||
});
|
||||
probe.state.follows = true;
|
||||
await assert.rejects(probe.loadAt(5_000, { replace: true }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
assert.equal(probe.feed.error, null);
|
||||
assert.equal(probe.feed.loading, false);
|
||||
assert.deepEqual(probe.applied, []);
|
||||
});
|
||||
@@ -99,7 +99,7 @@ function finiteOrNull(value) {
|
||||
* @returns {object|null} Subject info, or null without a usable fix.
|
||||
*/
|
||||
export function vesselCockpitInfo(record) {
|
||||
if (!record?.mmsi) return null;
|
||||
if (!record?.id) return null;
|
||||
const latitude = Number(record.lat);
|
||||
const longitude = Number(record.lon);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
@@ -111,9 +111,9 @@ export function vesselCockpitInfo(record) {
|
||||
return {
|
||||
kind: 'vessel',
|
||||
layerId: VESSEL_COCKPIT_LAYER_ID,
|
||||
id: String(record.mmsi),
|
||||
mmsi: String(record.mmsi),
|
||||
name: String(record.name || record.mmsi),
|
||||
id: String(record.id),
|
||||
mmsi: String(record.mmsi || ''),
|
||||
name: String(record.name || record.mmsi || record.id),
|
||||
type: String(record.type || ''),
|
||||
typeLabel: displayVesselType(record.type),
|
||||
destination: String(record.destination || '').trim(),
|
||||
|
||||
@@ -43,6 +43,7 @@ test('an unknown or missing type has no eye height of its own', () => {
|
||||
|
||||
test('vessel info faces the true heading and moves along course over ground', () => {
|
||||
const info = vesselCockpitInfo({
|
||||
id: '211000000',
|
||||
mmsi: '211000000',
|
||||
name: 'NORDIC STAR',
|
||||
type: '70',
|
||||
@@ -58,6 +59,7 @@ test('vessel info faces the true heading and moves along course over ground', ()
|
||||
assert.equal(info.kind, 'vessel');
|
||||
assert.equal(info.layerId, VESSEL_COCKPIT_LAYER_ID);
|
||||
assert.equal(info.id, '211000000');
|
||||
assert.equal(info.mmsi, '211000000');
|
||||
assert.equal(info.typeLabel, 'CARGO');
|
||||
assert.equal(info.track, 90);
|
||||
assert.equal(info.motionCourse, 95);
|
||||
@@ -70,7 +72,7 @@ test('vessel info faces the true heading and moves along course over ground', ()
|
||||
});
|
||||
|
||||
test('a missing heading or course falls back to the other', () => {
|
||||
const base = { mmsi: '1', lat: 1, lon: 1, speed: 3 };
|
||||
const base = { id: '1', mmsi: '1', lat: 1, lon: 1, speed: 3 };
|
||||
assert.equal(vesselCockpitInfo({ ...base, course: 40 }).track, 40);
|
||||
assert.equal(vesselCockpitInfo({ ...base, heading: 50 }).motionCourse, 50);
|
||||
const neither = vesselCockpitInfo(base);
|
||||
@@ -79,7 +81,7 @@ test('a missing heading or course falls back to the other', () => {
|
||||
});
|
||||
|
||||
test('the subject says whether its track is the heading or the course', () => {
|
||||
const base = { mmsi: '1', lat: 1, lon: 1, speed: 3 };
|
||||
const base = { id: '1', mmsi: '1', lat: 1, lon: 1, speed: 3 };
|
||||
assert.equal(
|
||||
vesselCockpitInfo({ ...base, heading: 50, course: 40 }).trackSource,
|
||||
'heading',
|
||||
@@ -99,11 +101,12 @@ test('the subject says whether its track is the heading or the course', () => {
|
||||
test('a vessel without identity or position is not a subject', () => {
|
||||
assert.equal(vesselCockpitInfo(null), null);
|
||||
assert.equal(vesselCockpitInfo({ lat: 1, lon: 1 }), null);
|
||||
assert.equal(vesselCockpitInfo({ mmsi: '1', lat: 'x', lon: 1 }), null);
|
||||
assert.equal(vesselCockpitInfo({ id: '1', mmsi: '1', lat: 'x', lon: 1 }), null);
|
||||
});
|
||||
|
||||
test('a vessel missing from recent refreshes reads as stale', () => {
|
||||
const info = vesselCockpitInfo({
|
||||
id: '1',
|
||||
mmsi: '1',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
@@ -111,3 +114,20 @@ test('a vessel missing from recent refreshes reads as stale', () => {
|
||||
});
|
||||
assert.equal(info.stale, true);
|
||||
});
|
||||
|
||||
test('a vessel is the subject by its id; an MMSI is only shown when known', () => {
|
||||
const info = vesselCockpitInfo({
|
||||
id: 'b6a7c1e2-provider-id',
|
||||
mmsi: '',
|
||||
name: 'NORDIC STAR',
|
||||
lat: 54.5,
|
||||
lon: 10.2,
|
||||
});
|
||||
assert.equal(info.id, 'b6a7c1e2-provider-id');
|
||||
assert.equal(info.mmsi, '');
|
||||
assert.equal(
|
||||
vesselCockpitInfo({ mmsi: '211000000', lat: 54.5, lon: 10.2 }),
|
||||
null,
|
||||
'a record with no id is unkeyed and cannot be ridden',
|
||||
);
|
||||
});
|
||||
@@ -47,7 +47,7 @@ export function createEvidence({
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
id: record.mmsi,
|
||||
id: record.id,
|
||||
show: bb?.show === true,
|
||||
alpha: bb?.color?.alpha ?? null,
|
||||
x: screen?.x ?? null,
|
||||
|
||||
@@ -10,6 +10,9 @@ import { createCards } from './cards.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createEvidence } from './evidence.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { followsClock } from './policy.js';
|
||||
import { createSnapshotTimeline } from '../../time/snapshotTimeline.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
/** Compose one vessel layer with application-owned scene services. */
|
||||
export function createVesselLayer({ source, services, options = {} } = {}) {
|
||||
const vesselState = createVesselState({ source, services });
|
||||
@@ -31,6 +34,7 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
|
||||
tracking: parts.tracking,
|
||||
selection: parts.selection,
|
||||
cards: parts.cards,
|
||||
followsClock: () => followsClock(vesselState),
|
||||
});
|
||||
|
||||
parts.ingestion = createIngestion({
|
||||
@@ -50,6 +54,29 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
|
||||
setSourceLabel: (source) => {
|
||||
layer.source = source;
|
||||
},
|
||||
followsClock: () => followsClock(vesselState),
|
||||
reloadAtClock: () => vesselState.timeline.reload(),
|
||||
});
|
||||
// Vessels follow the world clock while the source declares a timeline.
|
||||
vesselState.timeline = createSnapshotTimeline({
|
||||
id: 'ais-live-vessels',
|
||||
clock: worldClock,
|
||||
source: () => vesselState._source,
|
||||
load: (atMs, { signal, replace }) =>
|
||||
parts.ingestion.loadAt(atMs, { signal, replace }),
|
||||
clear: () => {
|
||||
const { state } = vesselState;
|
||||
if (state.viewer)
|
||||
parts.snapshots.reconcileVessels(state.viewer, [], {
|
||||
complete: true,
|
||||
replace: true,
|
||||
});
|
||||
state.feed.count = state.records.all.length;
|
||||
state.feed.lastUpdate = null;
|
||||
},
|
||||
resumeLive: () => {
|
||||
void parts.ingestion.loadLivePositions(vesselState.state.viewer);
|
||||
},
|
||||
});
|
||||
Object.assign(
|
||||
layer,
|
||||
|
||||
@@ -21,9 +21,36 @@ export function createIngestion({
|
||||
settleFirstConnect,
|
||||
now,
|
||||
setSourceLabel,
|
||||
followsClock = () => false,
|
||||
reloadAtClock = () => {},
|
||||
}) {
|
||||
async function loadLivePositions(viewer) {
|
||||
if (!viewer || feed.loading) return;
|
||||
// Following the world clock, the layer shows the snapshots loadAt applies.
|
||||
if (followsClock()) return;
|
||||
await loadPositions(viewer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the source's snapshot at world time `atMs` (its timeline says it
|
||||
* can answer one), in place of every vessel when `replace`. Resolves
|
||||
* whether it was applied.
|
||||
* @param {number} atMs
|
||||
* @param {{signal?: AbortSignal|null, replace?: boolean}} [options]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function loadAt(atMs, { signal = null, replace = false } = {}) {
|
||||
// Whatever polls or loads for another time no longer applies.
|
||||
if (replace && feed.loading) {
|
||||
feed.abort?.abort();
|
||||
feed.loading = false;
|
||||
feed.abort = null;
|
||||
}
|
||||
return loadPositions(readViewer(), { atMs, replace, signal });
|
||||
}
|
||||
|
||||
async function loadPositions(viewer, { atMs, replace = false, signal } = {}) {
|
||||
const timed = atMs !== undefined;
|
||||
if (!viewer || feed.loading) return false;
|
||||
feed.loading = true;
|
||||
feed.loadingLabel = feed.loaded ? 'refreshing...' : 'loading...';
|
||||
const requestController = new AbortController();
|
||||
@@ -33,21 +60,29 @@ export function createIngestion({
|
||||
try {
|
||||
// Combine the layer's teardown-abort with a hard timeout so a hung upstream
|
||||
// can't wedge the poll indefinitely (parity with the track fetch + flights).
|
||||
const signal =
|
||||
const requestSignal =
|
||||
typeof AbortSignal.any === 'function'
|
||||
? AbortSignal.any([
|
||||
requestController.signal,
|
||||
AbortSignal.timeout(10000),
|
||||
...(signal ? [signal] : []),
|
||||
])
|
||||
: requestController.signal;
|
||||
// Ask for the vessels in view; a wide view asks for every vessel.
|
||||
const area = readArea(viewer);
|
||||
feed.lastArea = area;
|
||||
const snapshot = await readSource().getSnapshot(
|
||||
{ maxRows: getRowLimit(), ...(area ? { area } : {}) },
|
||||
{ signal },
|
||||
{
|
||||
maxRows: getRowLimit(),
|
||||
...(area ? { area } : {}),
|
||||
...(timed ? { atMs } : {}),
|
||||
},
|
||||
{ signal: requestSignal },
|
||||
);
|
||||
if (!ownsAisRequest(requestController, requestSessionId)) return;
|
||||
if (!ownsAisRequest(requestController, requestSessionId)) return false;
|
||||
if (signal?.aborted) return false;
|
||||
// The clock left the live edge while this poll was out.
|
||||
if (!timed && followsClock()) return false;
|
||||
setSourceLabel(snapshot.source);
|
||||
// Map observations into the existing display store; source fields stop here.
|
||||
applyAisFeedSnapshot(viewer, {
|
||||
@@ -67,8 +102,12 @@ export function createIngestion({
|
||||
: new Date(snapshot.observedAtMs).toISOString(),
|
||||
silentForMs: snapshot.silentForMs,
|
||||
reconnectAttempt: snapshot.reconnectAttempt,
|
||||
...(replace && { replace }),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
// A snapshot at a time the source dropped for a newer one is no failure.
|
||||
if (timed && error?.name === 'AbortError') throw error;
|
||||
if (
|
||||
ownsAisRequest(requestController, requestSessionId) &&
|
||||
error?.name !== 'AbortError'
|
||||
@@ -76,6 +115,7 @@ export function createIngestion({
|
||||
markUnavailable(error?.message || msg('AIS live load failed'));
|
||||
console.warn('[Data:ais-live-vessels]', feed.error, error);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (
|
||||
feed.abort === requestController &&
|
||||
@@ -113,6 +153,9 @@ export function createIngestion({
|
||||
feed.acceptedRowCount = snapshot.acceptedRowCount;
|
||||
feed.partial = payload?.complete === false;
|
||||
|
||||
// At another time, a snapshot with no vessels shows none, not the last.
|
||||
if (payload?.replace && snapshot.acceptedRowCount === 0)
|
||||
applyRows(viewer, [], { complete: true, replace: true });
|
||||
if (snapshot.acceptedRowCount === 0 && !snapshot.emptyCoverage) {
|
||||
feed.count = readCount();
|
||||
feed.stale = feed.count > 0 || Boolean(payload?.refreshing);
|
||||
@@ -139,6 +182,7 @@ export function createIngestion({
|
||||
settleFirstConnect('ready');
|
||||
applyRows(viewer, snapshot.acceptedRows, {
|
||||
complete: payload?.complete !== false,
|
||||
...(payload?.replace && { replace: true }),
|
||||
});
|
||||
feed.count = readCount();
|
||||
feed.stale =
|
||||
@@ -157,9 +201,11 @@ export function createIngestion({
|
||||
return { reconciled: true, ...snapshot };
|
||||
}
|
||||
|
||||
/** The layer's row for a source record: keyed by its id, MMSI shown when known. */
|
||||
function vesselDisplayRow(record) {
|
||||
return {
|
||||
mmsi: record.id,
|
||||
id: record.id,
|
||||
mmsi: String(record.mmsi ?? '').trim(),
|
||||
reference: record.reference,
|
||||
lat: record.latitude,
|
||||
lon: record.longitude,
|
||||
@@ -180,10 +226,16 @@ export function createIngestion({
|
||||
}
|
||||
/** Ask again when the view moved away from the last area asked for. */
|
||||
function refreshIfMoved(viewer) {
|
||||
if (!feed.enabled || feed.loading) return Promise.resolve();
|
||||
const following = followsClock();
|
||||
if (!feed.enabled || (feed.loading && !following)) return Promise.resolve();
|
||||
const active = viewer || readViewer();
|
||||
if (!viewAreaMovedEnough(feed.lastArea ?? null, readArea(active)))
|
||||
return Promise.resolve();
|
||||
// Following the world clock, the new area is asked for at the same time.
|
||||
if (following) {
|
||||
reloadAtClock();
|
||||
return Promise.resolve();
|
||||
}
|
||||
return loadLivePositions(active);
|
||||
}
|
||||
|
||||
@@ -196,6 +248,7 @@ export function createIngestion({
|
||||
|
||||
return {
|
||||
loadLivePositions,
|
||||
loadAt,
|
||||
refreshIfMoved,
|
||||
ownsAisRequest,
|
||||
applyAisFeedSnapshot,
|
||||
|
||||
@@ -116,7 +116,7 @@ export function createLifecycle({
|
||||
state.feed.abort = null;
|
||||
state.billboardCollection = null;
|
||||
state.records.all = [];
|
||||
state.records.byMmsi = new Map();
|
||||
state.records.byId = new Map();
|
||||
state.records.unkeyed = [];
|
||||
state.clickHandler = null;
|
||||
state.keyTarget = null;
|
||||
@@ -133,7 +133,7 @@ export function createLifecycle({
|
||||
state.selectedRecord = null;
|
||||
state.trail = null;
|
||||
state.trailPositions = [];
|
||||
state.trailMmsi = null;
|
||||
state.trailId = null;
|
||||
state.trailBackfillToken = 0;
|
||||
}
|
||||
const methods = {
|
||||
@@ -188,9 +188,9 @@ export function createLifecycle({
|
||||
});
|
||||
}
|
||||
// Pick-ownership (H2): vessel picks carry the record OBJECT as their id;
|
||||
// the registry resolver reduces it to the record's mmsi (a string key).
|
||||
// the registry resolver reduces it to the record's id (a string key).
|
||||
registerPickOwner('ais-live-vessels', (pickedId) =>
|
||||
state.records.byMmsi.has(pickedId),
|
||||
state.records.byId.has(pickedId),
|
||||
);
|
||||
restoreSpriteOrderOnEnable('ais', activeViewer);
|
||||
// Vessels are asked for by view, so ask again when the view moves away.
|
||||
@@ -200,10 +200,12 @@ export function createLifecycle({
|
||||
state.feed.removeViewListener = moveEnd.addEventListener(() => {
|
||||
components.ingestion.refreshIfMoved(activeViewer);
|
||||
});
|
||||
vesselState.timeline?.start();
|
||||
return components.ingestion.loadLivePositions(activeViewer);
|
||||
},
|
||||
|
||||
disable() {
|
||||
vesselState.timeline?.stop();
|
||||
removeViewListener();
|
||||
state.feed.enabled = false;
|
||||
invalidateAisSession();
|
||||
@@ -224,6 +226,7 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
vesselState.timeline?.stop();
|
||||
removeViewListener();
|
||||
const activeViewer = viewer || state.viewer;
|
||||
invalidateAisSession();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Where a vessel is at world time `tMs` while the layer follows the world
|
||||
* clock: between the fix it moved from and the fix it moved to, along the
|
||||
* great circle; at the earlier fix before it, at the newer fix after it. A
|
||||
* vessel is never projected past its newest fix.
|
||||
* @param {{lat: number, lon: number, motion?: {fromLat: number, fromLon: number,
|
||||
* fromMs: number, toLat: number, toLon: number, toMs: number}|null}} record
|
||||
* @param {number} tMs
|
||||
* @returns {{lat: number, lon: number}}
|
||||
*/
|
||||
export function vesselPositionAt(record, tMs) {
|
||||
const motion = record?.motion;
|
||||
if (
|
||||
!motion ||
|
||||
!Number.isFinite(tMs) ||
|
||||
!(motion.toMs > motion.fromMs) ||
|
||||
tMs >= motion.toMs
|
||||
)
|
||||
return record;
|
||||
if (tMs <= motion.fromMs) return { lat: motion.fromLat, lon: motion.fromLon };
|
||||
const fraction = (tMs - motion.fromMs) / (motion.toMs - motion.fromMs);
|
||||
return greatCircleAt(
|
||||
motion.fromLat,
|
||||
motion.fromLon,
|
||||
motion.toLat,
|
||||
motion.toLon,
|
||||
fraction,
|
||||
);
|
||||
}
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
/** The point `fraction` of the way from one position to another. */
|
||||
function greatCircleAt(lat1, lon1, lat2, lon2, fraction) {
|
||||
const p1 = unit(lat1, lon1);
|
||||
const p2 = unit(lat2, lon2);
|
||||
const dot = Math.min(
|
||||
1,
|
||||
Math.max(-1, p1[0] * p2[0] + p1[1] * p2[1] + p1[2] * p2[2]),
|
||||
);
|
||||
const angle = Math.acos(dot);
|
||||
if (angle < 1e-12) return { lat: lat2, lon: lon2 };
|
||||
const a = Math.sin((1 - fraction) * angle) / Math.sin(angle);
|
||||
const b = Math.sin(fraction * angle) / Math.sin(angle);
|
||||
const x = a * p1[0] + b * p2[0];
|
||||
const y = a * p1[1] + b * p2[1];
|
||||
const z = a * p1[2] + b * p2[2];
|
||||
return {
|
||||
lat: Math.atan2(z, Math.hypot(x, y)) / RAD,
|
||||
lon: Math.atan2(y, x) / RAD,
|
||||
};
|
||||
}
|
||||
|
||||
function unit(lat, lon) {
|
||||
const phi = lat * RAD;
|
||||
const lambda = lon * RAD;
|
||||
return [
|
||||
Math.cos(phi) * Math.cos(lambda),
|
||||
Math.cos(phi) * Math.sin(lambda),
|
||||
Math.sin(phi),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { vesselPositionAt } from './motion.js';
|
||||
|
||||
const record = {
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
motion: {
|
||||
fromLat: 0,
|
||||
fromLon: 0,
|
||||
fromMs: 1_000,
|
||||
toLat: 1,
|
||||
toLon: 1,
|
||||
toMs: 3_000,
|
||||
},
|
||||
};
|
||||
|
||||
test('a vessel holds at its fixes and moves between them along the great circle', () => {
|
||||
assert.deepEqual(vesselPositionAt(record, 500), { lat: 0, lon: 0 });
|
||||
assert.equal(vesselPositionAt(record, 3_000), record);
|
||||
assert.equal(vesselPositionAt(record, 9_000), record, 'never projected past');
|
||||
const half = vesselPositionAt(record, 2_000);
|
||||
assert.ok(Math.abs(half.lat - 0.5) < 1e-3);
|
||||
assert.ok(Math.abs(half.lon - 0.5) < 1e-3);
|
||||
});
|
||||
|
||||
test('a vessel with no move is at its record', () => {
|
||||
const still = { lat: 4, lon: 5 };
|
||||
assert.equal(vesselPositionAt(still, 2_000), still);
|
||||
assert.equal(vesselPositionAt({ ...still, motion: null }, 2_000).lat, 4);
|
||||
});
|
||||
@@ -98,7 +98,7 @@ test('vessel construction is inert and each instance owns its records, icon cach
|
||||
const two = createVesselState({ source, services: services() });
|
||||
for (const key of ['state', 'shipIconCache', '_scratchFocusScreen'])
|
||||
assert.notEqual(one[key], two[key]);
|
||||
assert.notEqual(one.state.records.byMmsi, two.state.records.byMmsi);
|
||||
assert.notEqual(one.state.records.byId, two.state.records.byId);
|
||||
assert.notEqual(one.state.feed, two.state.feed);
|
||||
assert.notEqual(one.state.records.all, two.state.records.all);
|
||||
assert.notEqual(one.state.records.unkeyed, two.state.records.unkeyed);
|
||||
@@ -272,7 +272,7 @@ test('partial-feed expiry releases a selected vessel and its pending trail', asy
|
||||
layer.selectById('222');
|
||||
current = snapshot([observation('111')], { complete: false });
|
||||
await layer.update();
|
||||
assert.equal(layer.getSelectedInfo().mmsi, '222');
|
||||
assert.equal(layer.getSelectedInfo().id, '222');
|
||||
assert.equal(signal.aborted, false);
|
||||
advance(5 * 60 * 1000);
|
||||
await layer.update();
|
||||
@@ -421,3 +421,97 @@ test('vessel stats name the provider the last snapshot reported', async () => {
|
||||
await layer.update();
|
||||
assert.equal(layer.getStats().source, 'Datalastic');
|
||||
});
|
||||
|
||||
test('two vessels sharing an MMSI under different ids stay two vessels', async () => {
|
||||
const shared = (id, longitude) => ({
|
||||
...observation(id),
|
||||
mmsi: '123456789',
|
||||
longitude,
|
||||
name: `HULL ${id}`,
|
||||
});
|
||||
const { layer, primitives } = setup({
|
||||
getSnapshot: async () =>
|
||||
snapshot([shared('hull-a', 4.05), shared('hull-b', 4.25)]),
|
||||
});
|
||||
await layer.update();
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
assert.equal(primitives.size, 2);
|
||||
assert.equal(layer.hasContact('hull-a'), true);
|
||||
assert.equal(layer.hasContact('hull-b'), true);
|
||||
// An MMSI is not a key, but a link naming one still finds a vessel with it.
|
||||
assert.equal(layer.hasContact('123456789'), true);
|
||||
assert.equal(layer.hasContact('987654321'), false);
|
||||
assert.deepEqual(
|
||||
layer
|
||||
.getAllPositions()
|
||||
.map(({ id }) => id)
|
||||
.sort(),
|
||||
['hull-a', 'hull-b'],
|
||||
);
|
||||
assert.equal(layer.selectById('hull-b'), true);
|
||||
assert.deepEqual(
|
||||
[layer.getSelectedInfo().id, layer.getSelectedInfo().mmsi],
|
||||
['hull-b', '123456789'],
|
||||
);
|
||||
assert.equal(
|
||||
layer.selectById('123456789'),
|
||||
true,
|
||||
'a link naming the MMSI selects a vessel with it',
|
||||
);
|
||||
assert.equal(layer.getSelectedInfo().mmsi, '123456789');
|
||||
assert.equal(layer.selectById('hull-b'), true);
|
||||
// A lookup by the shared MMSI still finds a vessel that has it.
|
||||
assert.equal(layer.findByQuery('123456789').mmsi, '123456789');
|
||||
});
|
||||
|
||||
test('a vessel with an id and no MMSI renders and can be selected', async () => {
|
||||
const { layer, primitives } = setup({
|
||||
getSnapshot: async () =>
|
||||
snapshot([{ ...observation('7f3c9a10-provider-id'), name: 'NO MMSI' }]),
|
||||
});
|
||||
await layer.update();
|
||||
assert.equal(primitives.size, 1);
|
||||
const [primitive] = primitives;
|
||||
assert.equal(primitive.id.id, '7f3c9a10-provider-id');
|
||||
assert.equal(primitive.id.mmsi, '');
|
||||
assert.equal(layer.selectById('7f3c9a10-provider-id'), true);
|
||||
const info = layer.getSelectedInfo();
|
||||
assert.equal(info.id, '7f3c9a10-provider-id');
|
||||
assert.equal(info.mmsi, '');
|
||||
assert.equal(layer.getCockpitSubject().id, '7f3c9a10-provider-id');
|
||||
assert.equal(layer.testing._getVesselStateForTest().trailId, info.id);
|
||||
});
|
||||
|
||||
test('a vessel keyed by a UUID is found in any case and names its drawn fix', async () => {
|
||||
const uuid = '0770e3ca-b896-c307-f499-4f9df2b8f86b';
|
||||
const { layer } = setup({
|
||||
getSnapshot: async () =>
|
||||
snapshot([{ ...observation(uuid.toUpperCase()), name: 'UUID HULL' }]),
|
||||
});
|
||||
await layer.update();
|
||||
assert.equal(layer.getSelectedFix(), null, 'nothing selected');
|
||||
// A share link carries the UUID in lowercase.
|
||||
assert.equal(layer.hasContact(uuid), true);
|
||||
assert.equal(layer.selectById(uuid), true);
|
||||
const fix = layer.getSelectedFix();
|
||||
assert.equal(fix.id, uuid.toUpperCase());
|
||||
assert.ok(fix.position, 'where the vessel is drawn');
|
||||
assert.equal(fix.observedAtMs, 500);
|
||||
assert.equal(layer.hasContact('0770e3ca-b896-c307-f499-4f9df2b8f86c'), false);
|
||||
});
|
||||
|
||||
test('AIS feed rows stay keyed by their MMSI', async () => {
|
||||
const { vesselSnapshot } = await import('../../sources/live/vessels.js');
|
||||
const row = { mmsi: '235000001', lat: 51.93, lon: 4.05, name: 'TERN' };
|
||||
const { layer } = setup({
|
||||
getSnapshot: async () =>
|
||||
vesselSnapshot({ rows: [row, { ...row, lon: 4.2 }], status: 'live' }),
|
||||
});
|
||||
await layer.update();
|
||||
assert.equal(layer.getStats().count, 1, 'one MMSI is one vessel on AIS');
|
||||
assert.equal(layer.selectById('235000001'), true);
|
||||
assert.deepEqual(
|
||||
[layer.getSelectedInfo().id, layer.getSelectedInfo().mmsi],
|
||||
['235000001', '235000001'],
|
||||
);
|
||||
});
|
||||
@@ -53,6 +53,11 @@ export const TRAIL_MAX_POINTS = 400;
|
||||
|
||||
export const TRAIL_MIN_MOVE_M = 25;
|
||||
|
||||
/** Whether the layer shows the world clock's time instead of the live feed:
|
||||
* its source declares a timeline and the clock is away from live. */
|
||||
export const followsClock = (vesselState) =>
|
||||
vesselState.timeline?.active() === true;
|
||||
|
||||
export const DEFAULT_AIS_RUNTIME = Object.freeze({
|
||||
now: () => Date.now(),
|
||||
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
|
||||
+112
-48
@@ -24,9 +24,13 @@ import {
|
||||
REFRESH_MS,
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
VESSEL_LIFT_M,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||
|
||||
const UUID_KEY =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function createQueries({
|
||||
vesselState,
|
||||
services,
|
||||
@@ -37,6 +41,28 @@ export function createQueries({
|
||||
const { state } = vesselState;
|
||||
const { formatKnots } = services.labels;
|
||||
|
||||
/**
|
||||
* The vessel keyed `id`, else the first whose MMSI is `id`: a link or a
|
||||
* caller that names a vessel by its MMSI still finds it. A share link
|
||||
* carries a UUID key in lowercase, so a UUID matches in any case.
|
||||
* @param {string} id
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function recordFor(id) {
|
||||
const record = state.records.byId?.get(id);
|
||||
if (record) return record;
|
||||
if (UUID_KEY.test(id)) {
|
||||
const wanted = id.toLowerCase();
|
||||
for (const candidate of state.records.byId?.values() ?? [])
|
||||
if (candidate.id.toLowerCase() === wanted) return candidate;
|
||||
return null;
|
||||
}
|
||||
if (!/^\d{1,10}$/.test(id)) return null;
|
||||
for (const candidate of state.records.byId?.values() ?? [])
|
||||
if (candidate.mmsi === id) return candidate;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds until the server's next reconnect attempt, or 0 when none is
|
||||
* scheduled. Mirrors the flights layer's `retryInSec` chip affordance.
|
||||
@@ -174,8 +200,9 @@ export function createQueries({
|
||||
* fields are null, never NaN/undefined. navStatus is always null: the
|
||||
* /api/vessels proxy does not surface AIS NavigationalStatus, so it
|
||||
* cannot be derived client-side.
|
||||
* @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry.
|
||||
* @returns {{id: string|null, mmsi: string|null, name: string|null,
|
||||
* @param {Object|null|undefined} record - `state.records.byId`/`state.records.all` entry.
|
||||
* @returns {{id: string|null, vesselId: string|null, mmsi: string|null,
|
||||
* name: string|null,
|
||||
* lat: number|null, lon: number|null, speedKts: number|null,
|
||||
* courseDeg: number|null, shipType: string|null, destination: string|null,
|
||||
* navStatus: null}}
|
||||
@@ -187,10 +214,14 @@ export function createQueries({
|
||||
const t = String(v ?? '').trim();
|
||||
return t || null;
|
||||
};
|
||||
const vesselId = text(record?.id);
|
||||
const mmsi = text(record?.mmsi);
|
||||
const name = text(record?.name);
|
||||
return {
|
||||
id: name || mmsi,
|
||||
// A display label, as every layer's analyst `id` is; `vesselId` is the
|
||||
// key and `mmsi` the vessel's MMSI when known.
|
||||
id: name || mmsi || vesselId,
|
||||
vesselId,
|
||||
mmsi,
|
||||
name,
|
||||
lat: num(record?.lat),
|
||||
@@ -224,32 +255,32 @@ export function createQueries({
|
||||
* trail picks as no-ops. The interaction wire also reserves sibling-owned
|
||||
* picks before this reducer so their camera action cannot mutate AIS state.
|
||||
*
|
||||
* @param {{selectedMmsi?: string|number|null, pickedMmsi?: string|number|null,
|
||||
* @param {{selectedId?: string|number|null, pickedId?: string|number|null,
|
||||
* gesture?: 'click'|'escape'}} input - Current selection plus owned pick.
|
||||
* @returns {{action: 'none'|'select'|'deselect'}}
|
||||
*/
|
||||
|
||||
function reduceVesselSelection(input = {}) {
|
||||
const selectedMmsi = normalizeSelectionMmsi(input.selectedMmsi);
|
||||
const pickedMmsi = normalizeSelectionMmsi(input.pickedMmsi);
|
||||
const selectedId = normalizeSelectionId(input.selectedId);
|
||||
const pickedId = normalizeSelectionId(input.pickedId);
|
||||
const gesture = input.gesture || 'click';
|
||||
|
||||
if (gesture === 'escape') {
|
||||
return selectedMmsi ? { action: 'deselect' } : { action: 'none' };
|
||||
return selectedId ? { action: 'deselect' } : { action: 'none' };
|
||||
}
|
||||
if (gesture !== 'click') {
|
||||
return { action: 'none' };
|
||||
}
|
||||
if (pickedMmsi) {
|
||||
if (pickedMmsi === selectedMmsi) {
|
||||
if (pickedId) {
|
||||
if (pickedId === selectedId) {
|
||||
return { action: 'none' };
|
||||
}
|
||||
return { action: 'select' };
|
||||
}
|
||||
return selectedMmsi ? { action: 'deselect' } : { action: 'none' };
|
||||
return selectedId ? { action: 'deselect' } : { action: 'none' };
|
||||
}
|
||||
|
||||
function normalizeSelectionMmsi(value) {
|
||||
function normalizeSelectionId(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
const text = String(value).trim();
|
||||
return text || null;
|
||||
@@ -274,9 +305,10 @@ export function createQueries({
|
||||
statsRefreshInterval: 1000,
|
||||
|
||||
/**
|
||||
* Find a vessel by exact MMSI or case-insensitive name substring.
|
||||
* @param {string|number} query MMSI or partial vessel name.
|
||||
* @returns {{ mmsi: string, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
|
||||
* Find a vessel by its exact id, exact MMSI or case-insensitive name
|
||||
* substring. Vessels can share an MMSI; the first that has it is found.
|
||||
* @param {string|number} query Vessel id, MMSI or partial vessel name.
|
||||
* @returns {{ id: string, mmsi: string, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
|
||||
*/
|
||||
findByQuery(query) {
|
||||
if (query === null || query === undefined) return null;
|
||||
@@ -285,9 +317,9 @@ export function createQueries({
|
||||
const q = String(query).trim();
|
||||
if (!q) return null;
|
||||
|
||||
let record = null;
|
||||
if (/^\d+$/.test(q)) {
|
||||
record = state.records.byMmsi.get(q) || null;
|
||||
let record = state.records.byId.get(q) || null;
|
||||
if (!record && /^\d+$/.test(q)) {
|
||||
record = records.find((r) => r.mmsi === q) || null;
|
||||
}
|
||||
if (!record) {
|
||||
const lower = q.toLowerCase();
|
||||
@@ -305,6 +337,7 @@ export function createQueries({
|
||||
components.rendering.getVisual(record).position;
|
||||
if (!position) return null;
|
||||
return {
|
||||
id: record.id,
|
||||
mmsi: record.mmsi,
|
||||
name: record.name,
|
||||
position,
|
||||
@@ -321,7 +354,7 @@ export function createQueries({
|
||||
* @param {Cesium.Cartesian3} centerCartesian Center of the search.
|
||||
* @param {number} rangeM Max distance in meters (non-finite = unbounded).
|
||||
* @param {number} [maxCount=25] Maximum entries to return.
|
||||
* @returns {Array<{ mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>}
|
||||
* @returns {Array<{ id: string, mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>}
|
||||
*/
|
||||
getNearby(centerCartesian, rangeM, maxCount = 25) {
|
||||
const records = state.records.all;
|
||||
@@ -341,6 +374,7 @@ export function createQueries({
|
||||
const distanceM = Cesium.Cartesian3.distance(centerCartesian, position);
|
||||
if (!Number.isFinite(distanceM) || distanceM > range) continue;
|
||||
entries.push({
|
||||
id: record.id,
|
||||
mmsi: record.mmsi,
|
||||
name: record.name,
|
||||
position,
|
||||
@@ -362,7 +396,7 @@ export function createQueries({
|
||||
* @returns {Array<{ id: string, name: string, latitude: number,
|
||||
* longitude: number, course: number|null, heading: number|null,
|
||||
* type: string, color: string, stale: boolean }>} Empty while the
|
||||
* layer is off. A ship with no MMSI has an `unkeyed:` id.
|
||||
* layer is off. A ship with no id has an `unkeyed:` id.
|
||||
*/
|
||||
getRadarContacts(lat, lon, rangeM, maxCount = 60) {
|
||||
const records = state.records.all;
|
||||
@@ -372,7 +406,7 @@ export function createQueries({
|
||||
const cap =
|
||||
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 60;
|
||||
const center = { lat, lon };
|
||||
const subject = state.cockpitSubjectMmsi;
|
||||
const subject = state.cockpitSubjectId;
|
||||
const finite = (value) => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const number = Number(value);
|
||||
@@ -381,7 +415,7 @@ export function createQueries({
|
||||
|
||||
const entries = [];
|
||||
records.forEach((record, index) => {
|
||||
if (subject && String(record.mmsi) === subject) return;
|
||||
if (subject && String(record.id) === subject) return;
|
||||
const distanceM = greatCircleMeters(center, {
|
||||
lat: record.lat,
|
||||
lon: record.lon,
|
||||
@@ -391,8 +425,8 @@ export function createQueries({
|
||||
});
|
||||
entries.sort((a, b) => a.distanceM - b.distanceM);
|
||||
return entries.slice(0, cap).map(({ record, index }) => ({
|
||||
// A ship with no MMSI still needs a key of its own on the scope.
|
||||
id: record.mmsi ? String(record.mmsi) : `unkeyed:${index}`,
|
||||
// A ship with no id still needs a key of its own on the scope.
|
||||
id: record.id ? String(record.id) : `unkeyed:${index}`,
|
||||
name: String(record.name || record.mmsi || ''),
|
||||
latitude: record.lat,
|
||||
longitude: record.lon,
|
||||
@@ -413,22 +447,22 @@ export function createQueries({
|
||||
* Whether this layer still carries a vessel, in O(1).
|
||||
*
|
||||
* Mirror of `flights.hasContact`: presence consumers must not infer absence
|
||||
* from the capped `getAllPositions` rows. `vesselMap` is MMSI-keyed.
|
||||
* from the capped `getAllPositions` rows. Records are keyed by id.
|
||||
* A disabled layer keeps its records, so it must decline rather than answer
|
||||
* from data the user can no longer see.
|
||||
* @param {string} mmsi Vessel identifier.
|
||||
* @param {string} id Vessel id.
|
||||
* @returns {boolean|null} Presence, or null when the layer is disabled or
|
||||
* holds no data and therefore cannot answer.
|
||||
*/
|
||||
hasContact(mmsi) {
|
||||
hasContact(id) {
|
||||
if (
|
||||
!state.feed.enabled ||
|
||||
!state.records.byMmsi ||
|
||||
state.records.byMmsi.size === 0
|
||||
!state.records.byId ||
|
||||
state.records.byId.size === 0
|
||||
)
|
||||
return null;
|
||||
if (!mmsi) return false;
|
||||
return state.records.byMmsi.has(String(mmsi).trim());
|
||||
if (!id) return false;
|
||||
return Boolean(recordFor(String(id).trim()));
|
||||
},
|
||||
|
||||
getAllPositions(maxCount = 800) {
|
||||
@@ -444,8 +478,8 @@ export function createQueries({
|
||||
const position = visual.billboard?.position || visual.position;
|
||||
if (!position) continue;
|
||||
result.push({
|
||||
id: record.mmsi,
|
||||
label: record.name || record.mmsi,
|
||||
id: record.id,
|
||||
label: record.name || record.mmsi || record.id,
|
||||
position,
|
||||
latitude: record.lat,
|
||||
longitude: record.lon,
|
||||
@@ -478,16 +512,16 @@ export function createQueries({
|
||||
},
|
||||
|
||||
/**
|
||||
* Select a vessel by MMSI via the same path as a map click
|
||||
* (highlight + HUD update).
|
||||
* @param {string|number} mmsi Vessel MMSI.
|
||||
* Select a vessel by id via the same path as a map click
|
||||
* (highlight + HUD update). An AIS feed's vessels are keyed by MMSI.
|
||||
* @param {string|number} id Vessel id.
|
||||
* @returns {boolean} True if a matching vessel was selected.
|
||||
*/
|
||||
selectById(mmsi) {
|
||||
if (mmsi === null || mmsi === undefined) return false;
|
||||
const target = String(mmsi).trim();
|
||||
selectById(id) {
|
||||
if (id === null || id === undefined) return false;
|
||||
const target = String(id).trim();
|
||||
if (!target) return false;
|
||||
const record = state.records.byMmsi.get(target);
|
||||
const record = recordFor(target);
|
||||
if (!record) return false;
|
||||
components.selection.selectVessel(record);
|
||||
return true;
|
||||
@@ -504,12 +538,13 @@ export function createQueries({
|
||||
|
||||
/**
|
||||
* Get info about the currently selected vessel.
|
||||
* @returns {{ mmsi: string, name: string, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
|
||||
* @returns {{ id: string, mmsi: string, name: string, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
|
||||
*/
|
||||
getSelectedInfo() {
|
||||
const record = state.selectedRecord;
|
||||
if (!record) return null;
|
||||
return {
|
||||
id: record.id,
|
||||
mmsi: record.mmsi,
|
||||
name: record.name,
|
||||
latitude: record.lat,
|
||||
@@ -520,6 +555,33 @@ export function createQueries({
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Where the selected vessel is drawn now, for a share link that names it:
|
||||
* its id, its drawn position and the time that position stands for (the
|
||||
* world time while the layer follows the clock, else its fix's time).
|
||||
* @returns {{ id: string, position: Cesium.Cartesian3, observedAtMs: number|null }|null}
|
||||
* Null when the layer is off, nothing is selected or it is not drawn.
|
||||
*/
|
||||
getSelectedFix() {
|
||||
if (!state.feed.enabled) return null;
|
||||
const record = state.selectedRecord;
|
||||
if (!record?.id) return null;
|
||||
const visual = components.rendering.getVisual(record);
|
||||
const position = visual?.billboard?.position || visual?.position;
|
||||
if (!position) return null;
|
||||
const fixEpoch = record.lastPositionEpoch;
|
||||
const observedAtMs = followsClock(vesselState)
|
||||
? vesselState.worldNow()
|
||||
: Number.isFinite(fixEpoch)
|
||||
? fixEpoch * 1000
|
||||
: (record.fixReceivedAtMs ?? null);
|
||||
return {
|
||||
id: record.id,
|
||||
position,
|
||||
observedAtMs: Number.isFinite(observedAtMs) ? observedAtMs : null,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Describe the selected vessel for the Cockpit bridge view.
|
||||
* @returns {object|null} `vesselCockpitInfo` fields plus `seaSurfaceHeightM`
|
||||
@@ -545,16 +607,16 @@ export function createQueries({
|
||||
/**
|
||||
* Mark the vessel whose bridge the Cockpit camera occupies, or release it.
|
||||
* Its sprite, card and detection bracket stay hidden while it is held.
|
||||
* @param {string|number|null} mmsi Subject MMSI, or null to release.
|
||||
* @param {string|number|null} id Subject vessel id, or null to release.
|
||||
* @returns {boolean} Always true.
|
||||
*/
|
||||
setCockpitSubject(mmsi) {
|
||||
const next = normalizeSelectionMmsi(mmsi);
|
||||
if (state.cockpitSubjectMmsi === next) return true;
|
||||
state.cockpitSubjectMmsi = next;
|
||||
setCockpitSubject(id) {
|
||||
const next = normalizeSelectionId(id);
|
||||
if (state.cockpitSubjectId === next) return true;
|
||||
state.cockpitSubjectId = next;
|
||||
// The ridden ship's trail runs from under the eye: hidden, its history
|
||||
// kept, while the bridge holds the ship it belongs to.
|
||||
state.trail?.setVisible?.(!next || next !== state.trailMmsi);
|
||||
state.trail?.setVisible?.(!next || next !== state.trailId);
|
||||
components.rendering.updateVisibility(true);
|
||||
return true;
|
||||
},
|
||||
@@ -607,7 +669,7 @@ export function createQueries({
|
||||
if (!position) continue;
|
||||
result.push({
|
||||
position,
|
||||
sourceId: record.mmsi,
|
||||
sourceId: record.id,
|
||||
id: record.name || record.mmsi || 'VESSEL',
|
||||
type: 'SEA',
|
||||
skipLabel: record === selected,
|
||||
@@ -660,6 +722,8 @@ export function createQueries({
|
||||
retryInSec: aisRetryInSec(),
|
||||
// Same field as flights and military, so Contacts names the provider.
|
||||
source: layer.source,
|
||||
// Following the world clock, the snapshot's age is told from its time.
|
||||
...(followsClock(vesselState) && { atMs: vesselState.worldNow() }),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -673,7 +737,7 @@ export function createQueries({
|
||||
mapAnalystRecord,
|
||||
vesselDatumHeightM,
|
||||
reduceVesselSelection,
|
||||
normalizeSelectionMmsi,
|
||||
normalizeSelectionId,
|
||||
methods,
|
||||
};
|
||||
}
|
||||
@@ -134,7 +134,7 @@ test("the ridden ship's trail is hidden while the bridge holds it", () => {
|
||||
};
|
||||
const layer = layerWith([record('own', 0), record('other', 2)], {
|
||||
trail,
|
||||
trailMmsi: 'own',
|
||||
trailId: 'own',
|
||||
});
|
||||
layer.setCockpitSubject('own');
|
||||
assert.deepEqual(shown, [false]);
|
||||
|
||||
@@ -3,17 +3,25 @@ import {
|
||||
SELECTED_PIN_REFRESHES,
|
||||
} from './recordPolicy.js';
|
||||
|
||||
/** Normalize AIS display fields without allocating scene resources. */
|
||||
/**
|
||||
* Normalize AIS display fields without allocating scene resources. `id` is the
|
||||
* vessel's key, the stable id its source gives; a row that names none (an
|
||||
* AIS feed's own rows) is keyed by its MMSI. `mmsi` is only an attribute,
|
||||
* shown when known: vessels can share one. A row with neither is unkeyed.
|
||||
*/
|
||||
export function normalizeVessel(row) {
|
||||
const lat = Number(row.lat);
|
||||
const lon = Number(row.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const mmsi = String(row.mmsi || '').trim();
|
||||
const id = String(row.id ?? mmsi).trim();
|
||||
return {
|
||||
lat,
|
||||
lon,
|
||||
name: String(row.name || row.mmsi || 'VESSEL'),
|
||||
mmsi: String(row.mmsi || '').trim(),
|
||||
reference: row.reference ?? String(row.mmsi || '').trim(),
|
||||
name: String(row.name || mmsi || 'VESSEL'),
|
||||
id,
|
||||
mmsi,
|
||||
reference: row.reference ?? id,
|
||||
imo: String(row.imo || ''),
|
||||
type: String(row.type || ''),
|
||||
destination: String(row.destination || ''),
|
||||
@@ -44,17 +52,51 @@ function finiteNumber(value) {
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The move from a record's fix to the next row's, when the fix moves on in
|
||||
* time; otherwise the move it was making, if the row repeats its fix.
|
||||
*/
|
||||
function motionBetween(record, next) {
|
||||
const fromEpoch = record.lastPositionEpoch;
|
||||
const toEpoch = next.lastPositionEpoch;
|
||||
if (fromEpoch === toEpoch) return record.motion ?? null;
|
||||
if (!Number.isFinite(fromEpoch) || !Number.isFinite(toEpoch)) return null;
|
||||
if (!(toEpoch > fromEpoch)) return null;
|
||||
return {
|
||||
fromLat: record.lat,
|
||||
fromLon: record.lon,
|
||||
fromMs: fromEpoch * 1000,
|
||||
toLat: next.lat,
|
||||
toLon: next.lon,
|
||||
toMs: toEpoch * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/** Own stable vessel records and bounded incomplete/selected retention. */
|
||||
export class VesselRecords {
|
||||
constructor({ now = () => Date.now() } = {}) {
|
||||
this.now = now;
|
||||
this.byMmsi = new Map();
|
||||
this.byId = new Map();
|
||||
this.unkeyed = [];
|
||||
this.all = [];
|
||||
}
|
||||
/**
|
||||
* Fold a refresh into the records. Following the world clock (`follow`),
|
||||
* a vessel whose fix moves on keeps where it moved from, so it can be drawn
|
||||
* between the two (see motion.js). `replace`: the rows are the world at
|
||||
* another time (the clock jumped), so they stand in for every vessel: one
|
||||
* they lack is gone at once, selected or not, and none moves from where it
|
||||
* was at the time the clock left.
|
||||
*/
|
||||
reconcile(
|
||||
rows,
|
||||
{ complete = true, selectedRecord = null, cap = Infinity },
|
||||
{
|
||||
complete = true,
|
||||
selectedRecord = null,
|
||||
cap = Infinity,
|
||||
replace = false,
|
||||
follow = false,
|
||||
},
|
||||
effects,
|
||||
) {
|
||||
const receivedAtMs = this.now();
|
||||
@@ -78,14 +120,14 @@ export class VesselRecords {
|
||||
if (!next) continue;
|
||||
next.receivedAtMs = receivedAtMs;
|
||||
stampFix(next, receivedAtMs, feedLatestEpoch);
|
||||
if (!next.mmsi) {
|
||||
if (!next.id) {
|
||||
effects.add(next);
|
||||
this.unkeyed.push(next);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(next.mmsi)) continue;
|
||||
seen.add(next.mmsi);
|
||||
const record = this.byMmsi.get(next.mmsi);
|
||||
if (seen.has(next.id)) continue;
|
||||
seen.add(next.id);
|
||||
const record = this.byId.get(next.id);
|
||||
if (record) {
|
||||
const before = effects.beforeUpdate(record);
|
||||
// A feed re-serving a cached row repeats its fix: the fix keeps the
|
||||
@@ -94,7 +136,10 @@ export class VesselRecords {
|
||||
record.fixReceivedAtMs = next.fixReceivedAtMs;
|
||||
record.fixFeedEpoch = next.fixFeedEpoch;
|
||||
}
|
||||
if (replace) record.motion = null;
|
||||
else if (follow) record.motion = motionBetween(record, next);
|
||||
record.reference = next.reference;
|
||||
record.mmsi = next.mmsi;
|
||||
record.receivedAtMs = next.receivedAtMs;
|
||||
record.lat = next.lat;
|
||||
record.lon = next.lon;
|
||||
@@ -112,11 +157,17 @@ export class VesselRecords {
|
||||
effects.updated(record, before);
|
||||
} else {
|
||||
effects.add(next);
|
||||
this.byMmsi.set(next.mmsi, next);
|
||||
this.byId.set(next.id, next);
|
||||
}
|
||||
}
|
||||
for (const [mmsi, record] of this.byMmsi) {
|
||||
if (seen.has(mmsi)) continue;
|
||||
for (const [id, record] of this.byId) {
|
||||
if (seen.has(id)) continue;
|
||||
if (replace) {
|
||||
effects.remove(record, record === selectedRecord);
|
||||
this.byId.delete(id);
|
||||
effects.removed(id);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!complete &&
|
||||
Number.isFinite(record.receivedAtMs) &&
|
||||
@@ -137,18 +188,18 @@ export class VesselRecords {
|
||||
}
|
||||
// Selection teardown still sees the record before its store entry is removed.
|
||||
effects.remove(record, record === selectedRecord);
|
||||
this.byMmsi.delete(mmsi);
|
||||
effects.removed(mmsi);
|
||||
this.byId.delete(id);
|
||||
effects.removed(id);
|
||||
}
|
||||
if (this.byMmsi.size + this.unkeyed.length > cap) {
|
||||
for (const [mmsi, record] of this.byMmsi) {
|
||||
if (this.byMmsi.size + this.unkeyed.length <= cap) break;
|
||||
if (seen.has(mmsi) || record === selectedRecord) continue;
|
||||
if (this.byId.size + this.unkeyed.length > cap) {
|
||||
for (const [id, record] of this.byId) {
|
||||
if (this.byId.size + this.unkeyed.length <= cap) break;
|
||||
if (seen.has(id) || record === selectedRecord) continue;
|
||||
effects.remove(record, false);
|
||||
this.byMmsi.delete(mmsi);
|
||||
effects.removed(mmsi);
|
||||
this.byId.delete(id);
|
||||
effects.removed(id);
|
||||
}
|
||||
}
|
||||
this.all = [...this.byMmsi.values(), ...this.unkeyed];
|
||||
this.all = [...this.byId.values(), ...this.unkeyed];
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@ function setup() {
|
||||
beforeUpdate: (record) => record.type,
|
||||
updated: (record, before) => events.push(['update', record, before]),
|
||||
remove: (record, evicted) =>
|
||||
events.push(['remove', record, evicted, store.byMmsi.has(record.mmsi)]),
|
||||
removed: (id) => events.push(['removed', id, store.byMmsi.has(id)]),
|
||||
events.push(['remove', record, evicted, store.byId.has(record.id)]),
|
||||
removed: (id) => events.push(['removed', id, store.byId.has(id)]),
|
||||
staleSelected: (record) => events.push(['stale', record]),
|
||||
};
|
||||
return {
|
||||
@@ -40,14 +40,14 @@ test('vessel reconciliation preserves record identity and first duplicate while
|
||||
row('222', { name: 'duplicate' }),
|
||||
row('bad', { lat: NaN }),
|
||||
]);
|
||||
assert.equal(probe.store.byMmsi.size, 2);
|
||||
assert.equal(probe.store.byMmsi.get('222').name, '222');
|
||||
const record = probe.store.byMmsi.get('111');
|
||||
assert.equal(probe.store.byId.size, 2);
|
||||
assert.equal(probe.store.byId.get('222').name, '222');
|
||||
const record = probe.store.byId.get('111');
|
||||
probe.reconcile([row('111', { lat: 51.94, type: 'Tanker' })]);
|
||||
assert.equal(probe.store.byMmsi.get('111'), record);
|
||||
assert.equal(probe.store.byId.get('111'), record);
|
||||
assert.equal(record.lat, 51.94);
|
||||
assert.equal(probe.events.find((event) => event[0] === 'update')[2], 'Cargo');
|
||||
assert.equal(probe.store.byMmsi.has('222'), false);
|
||||
assert.equal(probe.store.byId.has('222'), false);
|
||||
for (const field of ['position', 'surfacePosition', 'normal', 'billboard'])
|
||||
assert.equal(Object.hasOwn(record, field), false);
|
||||
assert.deepEqual(structuredClone(record), record);
|
||||
@@ -56,14 +56,14 @@ test('vessel reconciliation preserves record identity and first duplicate while
|
||||
test('selected vessel remains pinned for three complete misses and teardown precedes removal', () => {
|
||||
const probe = setup();
|
||||
probe.reconcile([row('111'), row('222')]);
|
||||
const selectedRecord = probe.store.byMmsi.get('111');
|
||||
const selectedRecord = probe.store.byId.get('111');
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
probe.reconcile([row('222')], { selectedRecord });
|
||||
assert.equal(probe.store.byMmsi.get('111'), selectedRecord);
|
||||
assert.equal(probe.store.byId.get('111'), selectedRecord);
|
||||
assert.equal(selectedRecord.missedRefreshes, i);
|
||||
}
|
||||
probe.reconcile([row('222')], { selectedRecord });
|
||||
assert.equal(probe.store.byMmsi.has('111'), false);
|
||||
assert.equal(probe.store.byId.has('111'), false);
|
||||
const removal = probe.events.find(
|
||||
(event) => event[0] === 'remove' && event[1] === selectedRecord,
|
||||
);
|
||||
@@ -77,13 +77,13 @@ test('selected vessel remains pinned for three complete misses and teardown prec
|
||||
test('partial retention respects age and row cap while preserving the selected contact', () => {
|
||||
const probe = setup();
|
||||
probe.reconcile([row('111'), row('222')], { cap: 2 });
|
||||
const selectedRecord = probe.store.byMmsi.get('111');
|
||||
const selectedRecord = probe.store.byId.get('111');
|
||||
probe.reconcile([row('333')], { complete: false, selectedRecord, cap: 2 });
|
||||
assert.deepEqual([...probe.store.byMmsi.keys()], ['111', '333']);
|
||||
assert.deepEqual([...probe.store.byId.keys()], ['111', '333']);
|
||||
assert.equal(selectedRecord.missedRefreshes, 1);
|
||||
probe.advance(5 * 60 * 1000);
|
||||
probe.reconcile([row('333')], { complete: false, selectedRecord, cap: 2 });
|
||||
assert.equal(probe.store.byMmsi.has('111'), false);
|
||||
assert.equal(probe.store.byId.has('111'), false);
|
||||
assert.equal(
|
||||
probe.events.find(
|
||||
(event) => event[0] === 'remove' && event[1] === selectedRecord,
|
||||
@@ -107,7 +107,7 @@ test('unkeyed records rebuild on each refresh and numeric coercion keeps unknown
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(probe.store.byMmsi.size, 0);
|
||||
assert.equal(probe.store.byId.size, 0);
|
||||
assert.equal(probe.store.all.length, 1);
|
||||
assert.equal(normalizeVessel(row('bad', { lon: Infinity })), null);
|
||||
});
|
||||
@@ -120,14 +120,14 @@ test("each fix keeps when it was first received and its refresh's newest fix", (
|
||||
row('333'),
|
||||
]);
|
||||
for (const mmsi of ['111', '222', '333']) {
|
||||
const record = probe.store.byMmsi.get(mmsi);
|
||||
const record = probe.store.byId.get(mmsi);
|
||||
assert.equal(record.fixFeedEpoch, 1_800_000_030, mmsi);
|
||||
assert.equal(record.fixReceivedAtMs, 1000, mmsi);
|
||||
}
|
||||
// A new fix is stamped afresh.
|
||||
probe.advance(60_000);
|
||||
probe.reconcile([row('111', { last_position_epoch: 1_800_000_050 })]);
|
||||
const record = probe.store.byMmsi.get('111');
|
||||
const record = probe.store.byId.get('111');
|
||||
assert.equal(record.fixFeedEpoch, 1_800_000_050);
|
||||
assert.equal(record.fixReceivedAtMs, 61_000);
|
||||
// The same fix re-served by later polls keeps its first receipt, while
|
||||
@@ -141,3 +141,43 @@ test("each fix keeps when it was first received and its refresh's newest fix", (
|
||||
assert.equal(record.fixFeedEpoch, 1_800_000_050);
|
||||
assert.equal(record.receivedAtMs, 91_000);
|
||||
});
|
||||
|
||||
test('vessels are keyed by their source id; a shared MMSI is only an attribute', () => {
|
||||
const probe = setup();
|
||||
probe.reconcile([
|
||||
row('123456789', { id: 'hull-a', name: 'A' }),
|
||||
row('123456789', { id: 'hull-b', name: 'B' }),
|
||||
row('', { id: 'hull-c', name: 'C' }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
[...probe.store.byId.keys()],
|
||||
['hull-a', 'hull-b', 'hull-c'],
|
||||
);
|
||||
assert.equal(probe.store.unkeyed.length, 0);
|
||||
const [a, b, c] = probe.store.all;
|
||||
assert.deepEqual([a.mmsi, b.mmsi, c.mmsi], ['123456789', '123456789', '']);
|
||||
assert.equal(a.reference, 'hull-a');
|
||||
// The number moves to another hull: the vessel keeps its id.
|
||||
probe.reconcile([
|
||||
row('', { id: 'hull-a', name: 'A' }),
|
||||
row('123456789', { id: 'hull-b', name: 'B' }),
|
||||
row('123456789', { id: 'hull-c', name: 'C' }),
|
||||
]);
|
||||
assert.equal(probe.store.byId.get('hull-a'), a);
|
||||
assert.equal(a.mmsi, '');
|
||||
assert.equal(probe.store.byId.get('hull-c').mmsi, '123456789');
|
||||
});
|
||||
|
||||
test('an AIS row with no id is keyed by its MMSI; one with neither is unkeyed', () => {
|
||||
assert.deepEqual(
|
||||
[
|
||||
normalizeVessel(row('366999712')).id,
|
||||
normalizeVessel(row('366999712')).mmsi,
|
||||
],
|
||||
['366999712', '366999712'],
|
||||
);
|
||||
const probe = setup();
|
||||
probe.reconcile([row(''), row('', { id: '' })]);
|
||||
assert.equal(probe.store.byId.size, 0);
|
||||
assert.equal(probe.store.unkeyed.length, 2);
|
||||
});
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
FOCUS_UPDATE_MS,
|
||||
LABEL_GRID_PX,
|
||||
CARD_MIN_SEP_PX,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
import { vesselPositionAt } from './motion.js';
|
||||
|
||||
/** How often vessels move on as the world clock plays (wall ms). */
|
||||
const FOLLOW_POSITION_MS = 250;
|
||||
|
||||
export function createRendering({
|
||||
vesselState,
|
||||
@@ -42,20 +47,16 @@ export function createRendering({
|
||||
visual = { billboard: record.billboard || null };
|
||||
visualRecords.set(record, visual);
|
||||
}
|
||||
// Following the world clock, a vessel is drawn where it was at the time.
|
||||
const { lat, lon } = followsClock(vesselState)
|
||||
? vesselPositionAt(record, vesselState.worldNow())
|
||||
: record;
|
||||
const heightM = components.queries.vesselDatumHeightM(
|
||||
components.tracking.currentGeoidN(record.lat, record.lon),
|
||||
components.tracking.currentGeoidN(lat, lon),
|
||||
VESSEL_LIFT_M,
|
||||
);
|
||||
visual.position = Cesium.Cartesian3.fromDegrees(
|
||||
record.lon,
|
||||
record.lat,
|
||||
heightM,
|
||||
);
|
||||
visual.surfacePosition = Cesium.Cartesian3.fromDegrees(
|
||||
record.lon,
|
||||
record.lat,
|
||||
0,
|
||||
);
|
||||
visual.position = Cesium.Cartesian3.fromDegrees(lon, lat, heightM);
|
||||
visual.surfacePosition = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
||||
visual.normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
|
||||
visual.position,
|
||||
new Cesium.Cartesian3(),
|
||||
@@ -212,9 +213,27 @@ export function createRendering({
|
||||
// own label publication requests a frame, and scheduling on that would loop.
|
||||
function installRuntime(viewer) {
|
||||
if (state.preRenderRemover || !viewer) return;
|
||||
state.preRenderRemover = viewer.scene.preRender.addEventListener(() =>
|
||||
updateVisibility(),
|
||||
);
|
||||
state.preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
||||
if (followsClock(vesselState)) followClockPositions();
|
||||
updateVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
// As the world clock plays, vessels between two fixes move on along them.
|
||||
let followedAtWallMs = -Infinity;
|
||||
let followedAtMs = null;
|
||||
function followClockPositions() {
|
||||
const wallMs = performance.now();
|
||||
if (wallMs - followedAtWallMs < FOLLOW_POSITION_MS) return;
|
||||
followedAtWallMs = wallMs;
|
||||
const atMs = vesselState.worldNow();
|
||||
if (atMs === followedAtMs) return;
|
||||
followedAtMs = atMs;
|
||||
for (const record of state.records.all) {
|
||||
if (!record.motion) continue;
|
||||
const visual = prepareRecordVisual(record);
|
||||
if (visual.billboard) visual.billboard.position = visual.position;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTrailingVisibilityPass(now) {
|
||||
@@ -288,7 +307,7 @@ export function createRendering({
|
||||
if (visual.billboard) {
|
||||
// The bridge camera sits on this sprite; showing it would fill the view.
|
||||
visual.billboard.show =
|
||||
visible && record.mmsi !== state.cockpitSubjectMmsi;
|
||||
visible && record.id !== state.cockpitSubjectId;
|
||||
if (visible && doRotations && scene) {
|
||||
const rot = screenProjectedRotation(
|
||||
scene,
|
||||
@@ -426,8 +445,7 @@ export function createRendering({
|
||||
const selected = state.selectedRecord;
|
||||
// From the bridge, the selected vessel is the viewer; its card would be
|
||||
// pinned to the camera position.
|
||||
const selectedCarded =
|
||||
selected && selected.mmsi !== state.cockpitSubjectMmsi;
|
||||
const selectedCarded = selected && selected.id !== state.cockpitSubjectId;
|
||||
const entries = selectedCarded
|
||||
? [components.cards.buildSelectedVesselCard(selected)]
|
||||
: [];
|
||||
@@ -513,17 +531,17 @@ export function createRendering({
|
||||
VESSEL_CARD_FADE_DISTANCE_M,
|
||||
);
|
||||
if (!card.interactive) return card;
|
||||
const mmsi = String(card.id || '').startsWith('vessel:')
|
||||
const id = String(card.id || '').startsWith('vessel:')
|
||||
? card.id.slice('vessel:'.length)
|
||||
: '';
|
||||
const mmsi = state.records.byId.get(id)?.mmsi || '';
|
||||
return {
|
||||
...card,
|
||||
accessibilityLabel: t('Focus vessel {name}, MMSI {mmsi}', {
|
||||
name: card.title,
|
||||
mmsi,
|
||||
}),
|
||||
accessibilityLabel: mmsi
|
||||
? t('Focus vessel {name}, MMSI {mmsi}', { name: card.title, mmsi })
|
||||
: t('Focus {label}', { label: card.title }),
|
||||
activate: () => {
|
||||
const record = state.records.byMmsi.get(mmsi);
|
||||
const record = state.records.byId.get(id);
|
||||
if (!record) return false;
|
||||
components.selection.selectAndFocusVessel(record);
|
||||
return true;
|
||||
@@ -555,7 +573,8 @@ export function createRendering({
|
||||
text &&
|
||||
text !== 'VESSEL' &&
|
||||
!/^MMSI\s*\d+$/i.test(text) &&
|
||||
text !== record.mmsi,
|
||||
text !== record.mmsi &&
|
||||
text !== record.id,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,13 +39,15 @@ export function createSelection({
|
||||
if (!state.feed.enabled) return;
|
||||
// The bridge view owns the selection: a click on open water must not
|
||||
// deselect the vessel the camera is riding.
|
||||
if (state.cockpitSubjectMmsi) return;
|
||||
if (state.cockpitSubjectId) return;
|
||||
const picked = pickContact
|
||||
? pickContact(viewer.scene, click.position)
|
||||
: viewer.scene.pick(click.position);
|
||||
const pickedId = resolvePickId(picked);
|
||||
let record = pickedId ? state.records.byMmsi.get(pickedId) : null;
|
||||
let record = pickedId ? state.records.byId.get(pickedId) : null;
|
||||
const rawId = picked?.id ?? picked?.primitive?.id;
|
||||
// Every vessel record carries an `mmsi` field ('' when unknown); its
|
||||
// key is `id`.
|
||||
const ownRecordPick =
|
||||
rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi');
|
||||
|
||||
@@ -85,10 +87,10 @@ export function createSelection({
|
||||
if (cycloneBackground && cardHit?.sourceId !== VESSEL_OVERLAY_SOURCE_ID)
|
||||
return;
|
||||
if (!record && cardHit) {
|
||||
const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
|
||||
const id = String(cardHit.entryId || '').startsWith('vessel:')
|
||||
? cardHit.entryId.slice('vessel:'.length)
|
||||
: null;
|
||||
record = mmsi ? state.records.byMmsi.get(mmsi) || null : null;
|
||||
record = id ? state.records.byId.get(id) || null : null;
|
||||
// A stale card id is not empty terrain and must not clear a newer
|
||||
// selection. The next paint will evict its hit rectangle.
|
||||
if (!record) return;
|
||||
@@ -100,8 +102,8 @@ export function createSelection({
|
||||
selectAndFocusVessel(record);
|
||||
} else {
|
||||
const transition = components.queries.reduceVesselSelection({
|
||||
selectedMmsi: state.selectedRecord?.mmsi,
|
||||
pickedMmsi: null,
|
||||
selectedId: state.selectedRecord?.id,
|
||||
pickedId: null,
|
||||
gesture: 'click',
|
||||
});
|
||||
if (transition.action === 'deselect') clearVesselInspection();
|
||||
@@ -123,17 +125,17 @@ export function createSelection({
|
||||
/** Select one live vessel and request one UI-owned camera transfer. */
|
||||
|
||||
function selectAndFocusVessel(record) {
|
||||
if (!record?.mmsi) return false;
|
||||
if (!record?.id) return false;
|
||||
const transition = components.queries.reduceVesselSelection({
|
||||
selectedMmsi: state.selectedRecord?.mmsi,
|
||||
pickedMmsi: record.mmsi,
|
||||
selectedId: state.selectedRecord?.id,
|
||||
pickedId: record.id,
|
||||
gesture: 'click',
|
||||
});
|
||||
if (transition.action === 'select') selectVessel(record);
|
||||
requestWorldFocus({
|
||||
kind: 'vessel',
|
||||
id: record.mmsi,
|
||||
label: record.name || record.mmsi,
|
||||
id: record.id,
|
||||
label: record.name || record.mmsi || record.id,
|
||||
position:
|
||||
components.rendering.getVisual(record).billboard?.position ||
|
||||
components.rendering.getVisual(record).position,
|
||||
@@ -160,7 +162,7 @@ export function createSelection({
|
||||
function onVesselKeyDown(event) {
|
||||
if (!state.feed.enabled || event.key !== 'Escape') return;
|
||||
const transition = components.queries.reduceVesselSelection({
|
||||
selectedMmsi: state.selectedRecord?.mmsi,
|
||||
selectedId: state.selectedRecord?.id,
|
||||
gesture: 'escape',
|
||||
});
|
||||
if (transition.action === 'deselect') {
|
||||
@@ -169,8 +171,8 @@ export function createSelection({
|
||||
}
|
||||
|
||||
function selectVessel(record) {
|
||||
if (!record?.mmsi) return;
|
||||
const reuseTrail = state.trailMmsi === record.mmsi;
|
||||
if (!record?.id) return;
|
||||
const reuseTrail = state.trailId === record.id;
|
||||
clearSelection({ preserveTrail: reuseTrail });
|
||||
state.selectedRecord = record;
|
||||
record.missedRefreshes = 0;
|
||||
@@ -188,7 +190,7 @@ export function createSelection({
|
||||
selectEntityContext(record);
|
||||
}
|
||||
// Track-history trail (PRD F3/F4): seed with the current position + async
|
||||
// backfill from the server-side per-MMSI ring buffer.
|
||||
// backfill from the source's recent track for this vessel.
|
||||
if (reuseTrail) {
|
||||
components.tracking.appendSelectedVesselTrailFix(record);
|
||||
} else {
|
||||
@@ -204,10 +206,10 @@ export function createSelection({
|
||||
*/
|
||||
|
||||
function registerSelectedContext(record) {
|
||||
if (!record?.mmsi) return null;
|
||||
if (!record?.id) return null;
|
||||
try {
|
||||
return registerEntityContext(record, {
|
||||
id: `ais-${record.mmsi}`,
|
||||
id: `ais-${record.id}`,
|
||||
layerId: 'ais-live-vessels',
|
||||
layerName: 'Live AIS Vessels',
|
||||
source: aisLiveVesselsLayer.source,
|
||||
@@ -215,7 +217,8 @@ export function createSelection({
|
||||
latitude: record.lat,
|
||||
longitude: record.lon,
|
||||
properties: {
|
||||
mmsi: record.mmsi,
|
||||
vesselId: record.id,
|
||||
mmsi: record.mmsi || null,
|
||||
type: record.type,
|
||||
speedKt: record.speed,
|
||||
course: record.course,
|
||||
|
||||
@@ -6,16 +6,25 @@ export function createVesselSnapshotRenderer({
|
||||
tracking,
|
||||
selection,
|
||||
cards,
|
||||
followsClock = () => false,
|
||||
}) {
|
||||
function reconcileVessels(viewer, rows, { complete = true } = {}) {
|
||||
/** `replace`: the rows are the world at another time (see VesselRecords). */
|
||||
function reconcileVessels(
|
||||
viewer,
|
||||
rows,
|
||||
{ complete = true, replace = false } = {},
|
||||
) {
|
||||
rendering.ensureCollections(viewer);
|
||||
const occluder = rendering.makeOccluder();
|
||||
const follow = followsClock();
|
||||
records.reconcile(
|
||||
rows,
|
||||
{
|
||||
complete,
|
||||
selectedRecord: state.selectedRecord,
|
||||
cap: rendering.renderRowLimit(),
|
||||
...(replace && { replace }),
|
||||
...(follow && { follow }),
|
||||
},
|
||||
{
|
||||
add(record) {
|
||||
@@ -35,7 +44,7 @@ export function createVesselSnapshotRenderer({
|
||||
const nextIcon = rendering.shipIcon(record, selected);
|
||||
if (nextIcon !== prevIcon) visual.billboard.image = nextIcon;
|
||||
}
|
||||
if (record.mmsi === state.trailMmsi)
|
||||
if (record.id === state.trailId)
|
||||
tracking.appendSelectedVesselTrailFix(record);
|
||||
if (selected) {
|
||||
cards.updateSelectedVesselHud(record);
|
||||
@@ -46,14 +55,17 @@ export function createVesselSnapshotRenderer({
|
||||
if (evicted) selection.clearVesselInspection({ evicted: true });
|
||||
rendering.removeRecordPrimitives(record);
|
||||
},
|
||||
removed(mmsi) {
|
||||
if (state.trailMmsi === mmsi) tracking.clearSelectedVesselTrail();
|
||||
removed(id) {
|
||||
if (state.trailId === id) tracking.clearSelectedVesselTrail();
|
||||
},
|
||||
staleSelected(record) {
|
||||
cards.updateSelectedVesselHud(record);
|
||||
},
|
||||
},
|
||||
);
|
||||
// The selected vessel at the new time: its trail starts there.
|
||||
if (replace && state.selectedRecord && state.trailId)
|
||||
tracking.startSelectedVesselTrail(state.selectedRecord);
|
||||
state.lastVisibilityUpdate = 0;
|
||||
rendering.updateVisibility(true);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createVesselFeed } from './ingestion.js';
|
||||
import { VesselRecords } from './records.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { DEFAULT_AIS_RUNTIME } from './policy.js';
|
||||
import { DEFAULT_AIS_RUNTIME, followsClock } from './policy.js';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
export function createVesselState({ source, services }) {
|
||||
const {
|
||||
@@ -31,6 +32,19 @@ export function createVesselState({ source, services }) {
|
||||
|
||||
vesselState._aisRuntime = DEFAULT_AIS_RUNTIME;
|
||||
|
||||
/** Follows the world clock while the source declares a timeline (set by the
|
||||
* layer); see followsClock in policy.js. */
|
||||
vesselState.timeline = null;
|
||||
|
||||
/** The world time the layer draws at while it follows the clock. */
|
||||
vesselState.worldNow = worldTimeFor('ais-live-vessels');
|
||||
|
||||
/** The time records are judged at: the world's while following the clock. */
|
||||
vesselState.recordsNow = () =>
|
||||
followsClock(vesselState)
|
||||
? vesselState.worldNow()
|
||||
: vesselState._aisRuntime.now();
|
||||
|
||||
vesselState._aisSessionSequence = 0;
|
||||
|
||||
/**
|
||||
@@ -48,7 +62,7 @@ export function createVesselState({ source, services }) {
|
||||
|
||||
vesselState.state = {
|
||||
feed: createVesselFeed(),
|
||||
records: new VesselRecords({ now: () => vesselState._aisRuntime.now() }),
|
||||
records: new VesselRecords({ now: () => vesselState.recordsNow() }),
|
||||
viewer: null,
|
||||
|
||||
billboardCollection: null,
|
||||
@@ -72,14 +86,14 @@ export function createVesselState({ source, services }) {
|
||||
activeFocusCount: 0,
|
||||
activeLabelCount: 0,
|
||||
selectedRecord: null,
|
||||
/** @type {string|null} MMSI whose bridge the Cockpit camera occupies; its sprite and card stay hidden. */
|
||||
cockpitSubjectMmsi: null,
|
||||
/** @type {string|null} Id of the vessel whose bridge the Cockpit camera occupies; its sprite and card stay hidden. */
|
||||
cockpitSubjectId: null,
|
||||
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
|
||||
trail: null,
|
||||
/** @type {Cesium.Cartesian3[]} Chronological trail vertices (oldest first) */
|
||||
trailPositions: [],
|
||||
/** @type {string|null} MMSI that owns the active selected-vessel trail. */
|
||||
trailMmsi: null,
|
||||
/** @type {string|null} Id of the vessel that owns the active selected-vessel trail. */
|
||||
trailId: null,
|
||||
/** @type {number} Monotonic token — invalidates in-flight backfill responses */
|
||||
trailBackfillToken: 0,
|
||||
trailAbort: null,
|
||||
|
||||
@@ -32,6 +32,11 @@ export function createTesting({
|
||||
function _setVesselStateForTest(options = {}) {
|
||||
components.lifecycle.resetState();
|
||||
const records = Array.isArray(options.records) ? options.records : [];
|
||||
// A test record that names no id is keyed by its MMSI, as an AIS feed's
|
||||
// own rows are (see normalizeVessel).
|
||||
for (const record of [...records, options.selectedRecord])
|
||||
if (record && typeof record === 'object' && record.id === undefined)
|
||||
record.id = String(record.mmsi || '').trim();
|
||||
state.viewer = options.viewer || null;
|
||||
state.feed.enabled = options.enabled !== false;
|
||||
state.feed.loaded = options.loaded === true;
|
||||
@@ -42,15 +47,15 @@ export function createTesting({
|
||||
state.feed.lastUpdate = options.lastUpdate ?? null;
|
||||
state.records.all = records;
|
||||
state.feed.count = records.length;
|
||||
state.records.byMmsi = new Map(
|
||||
state.records.byId = new Map(
|
||||
records
|
||||
.filter((record) => record?.mmsi)
|
||||
.map((record) => [record.mmsi, record]),
|
||||
.filter((record) => record?.id)
|
||||
.map((record) => [record.id, record]),
|
||||
);
|
||||
state.selectedRecord = options.selectedRecord || null;
|
||||
state.billboardCollection = options.billboardCollection || { remove() {} };
|
||||
state.trail = options.trail || null;
|
||||
state.trailMmsi = options.trailMmsi || null;
|
||||
state.trailId = options.trailId || null;
|
||||
state.trailPositions = Array.isArray(options.trailPositions)
|
||||
? [...options.trailPositions]
|
||||
: [];
|
||||
@@ -141,8 +146,8 @@ export function createTesting({
|
||||
lastMessageAt: state.feed.lastMessageAt,
|
||||
rawRowCount: state.feed.rawRowCount,
|
||||
acceptedRowCount: state.feed.acceptedRowCount,
|
||||
selectedMmsi: state.selectedRecord?.mmsi || null,
|
||||
trailMmsi: state.trailMmsi,
|
||||
selectedId: state.selectedRecord?.id || null,
|
||||
trailId: state.trailId,
|
||||
trailPositionCount: state.trailPositions.length,
|
||||
sessionId: state.feed.sessionId,
|
||||
firstConnectPhase: state.feed.firstConnectPhase,
|
||||
@@ -166,14 +171,14 @@ export function createTesting({
|
||||
/**
|
||||
* Read lifecycle ownership state without exposing the mutable state object.
|
||||
* Test-only seam.
|
||||
* @returns {{trailMmsi: string|null, trailPositionCount: number, vesselCount: number}}
|
||||
* @returns {{trailId: string|null, trailPositionCount: number, vesselCount: number}}
|
||||
*/
|
||||
|
||||
function _getVesselStateForTest() {
|
||||
return {
|
||||
trailMmsi: state.trailMmsi,
|
||||
trailId: state.trailId,
|
||||
trailPositionCount: state.trailPositions.length,
|
||||
vesselCount: state.records.byMmsi.size,
|
||||
vesselCount: state.records.byId.size,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
TRAIL_COLOR,
|
||||
TRAIL_MAX_POINTS,
|
||||
TRAIL_MIN_MOVE_M,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
|
||||
export function createTracking({
|
||||
@@ -85,7 +86,7 @@ export function createTracking({
|
||||
state.trailAbort?.abort();
|
||||
state.trailAbort = new AbortController();
|
||||
state.trailBackfillToken += 1;
|
||||
state.trailMmsi = record.mmsi;
|
||||
state.trailId = record.id;
|
||||
state.trailPositions = [];
|
||||
const current = vesselTrailPosition(record);
|
||||
if (current) state.trailPositions.push(current);
|
||||
@@ -96,32 +97,33 @@ export function createTracking({
|
||||
});
|
||||
}
|
||||
if (state.trail) state.trail.setPositions(state.trailPositions);
|
||||
backfillVesselTrail(
|
||||
record.mmsi,
|
||||
state.trailBackfillToken,
|
||||
record.reference,
|
||||
);
|
||||
backfillVesselTrail(record.id, state.trailBackfillToken, record.reference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget backfill from the server-side per-MMSI ring buffer
|
||||
* Fire-and-forget backfill from the server-side per-vessel ring buffer
|
||||
* (PRD F3 — "recent path" since server boot, not voyage history). Older
|
||||
* samples are spliced AHEAD of the live accumulation, capped at
|
||||
* TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/malformed)
|
||||
* silently keeps the live-only trail.
|
||||
* @param {string} mmsi - MMSI of the selected vessel.
|
||||
* @param {string} id - Key of the selected vessel.
|
||||
* @param {number} token - Backfill token captured at request time.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
|
||||
async function backfillVesselTrail(mmsi, token, reference = mmsi) {
|
||||
async function backfillVesselTrail(id, token, reference = id) {
|
||||
const owner = state.trailAbort;
|
||||
// Following the world clock, the track ends at the world's time.
|
||||
const atMs = followsClock(vesselState) ? vesselState.worldNow() : undefined;
|
||||
const signal = owner
|
||||
? AbortSignal.any([owner.signal, AbortSignal.timeout(8000)])
|
||||
: AbortSignal.timeout(8000);
|
||||
let samples = null;
|
||||
try {
|
||||
const track = await vesselState._source.getTrack?.(reference, { signal });
|
||||
const track = await vesselState._source.getTrack?.(
|
||||
reference,
|
||||
atMs === undefined ? { signal } : { signal, atMs },
|
||||
);
|
||||
samples = track?.records ?? null;
|
||||
} catch {
|
||||
return; // silent — keep the live-accumulated trail
|
||||
@@ -133,13 +135,19 @@ export function createTracking({
|
||||
token !== state.trailBackfillToken
|
||||
)
|
||||
return;
|
||||
if (state.trailMmsi !== mmsi) return;
|
||||
if (state.trailId !== id) return;
|
||||
|
||||
const older = [];
|
||||
for (const sample of samples) {
|
||||
const lat = Number(sample?.latitude);
|
||||
const lon = Number(sample?.longitude);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
// Following the clock, an undated sample may be later than the time.
|
||||
if (
|
||||
atMs !== undefined &&
|
||||
!(Number.isFinite(sample?.observedAtMs) && sample.observedAtMs <= atMs)
|
||||
)
|
||||
continue;
|
||||
// Per-sample N (≤ TRAIL_MAX_POINTS lookups) — same sea-surface datum as
|
||||
// the live vertices so the spliced trail is height-continuous.
|
||||
const heightM = components.queries.vesselDatumHeightM(
|
||||
@@ -186,7 +194,7 @@ export function createTracking({
|
||||
state.trailAbort?.abort();
|
||||
state.trailAbort = null;
|
||||
state.trailBackfillToken += 1;
|
||||
state.trailMmsi = null;
|
||||
state.trailId = null;
|
||||
state.trailPositions = [];
|
||||
if (state.trail) state.trail.clear();
|
||||
}
|
||||
|
||||
+698
-206
File diff suppressed because it is too large.
Load diff
@@ -59,6 +59,12 @@ export const SHARE_VIEW_FOLLOW_OFFSET_DEFAULT = Object.freeze({
|
||||
pitch: -35,
|
||||
});
|
||||
|
||||
/**
|
||||
* The follow range a thing view can carry, in whole metres: 1 to this (the
|
||||
* codec clamps to the same bound).
|
||||
*/
|
||||
export const SHARE_VIEW_FOLLOW_RANGE_MAX_M = 1e8;
|
||||
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
@@ -114,6 +120,27 @@ function enuBasis(lat, lon) {
|
||||
|
||||
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
|
||||
/**
|
||||
* The follow offset that puts the camera at `camera` (lat, lon, alt) while
|
||||
* following an entity at `fix`: the inverse of {@link followCameraFor}, on
|
||||
* the same sphere, in whole metres and degrees. Null when the camera is not
|
||||
* above the entity's local horizontal (a view from below it, or through the
|
||||
* earth, has no follow offset), or is at the entity.
|
||||
*/
|
||||
export function followOffsetFor(fix, camera) {
|
||||
const at = toEcef(fix.lat, fix.lon, fix.alt);
|
||||
const from = toEcef(camera.lat, camera.lon, camera.alt);
|
||||
const basis = enuBasis(fix.lat, fix.lon);
|
||||
const delta = [0, 1, 2].map((i) => from[i] - at[i]);
|
||||
const local = {
|
||||
east: dot(delta, basis.east),
|
||||
north: dot(delta, basis.north),
|
||||
up: dot(delta, basis.up),
|
||||
};
|
||||
if (!(local.up > 0)) return null;
|
||||
return followOffsetFromEnu(local);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the camera sits while following an entity at `fix` from `offset`,
|
||||
* and how it looks back at the entity, on a sphere of radius 6 371 000 m. A
|
||||
|
||||
+60
-5
@@ -62,7 +62,9 @@ import {
|
||||
export {
|
||||
SHARE_VIEW_ENTITY_KINDS,
|
||||
SHARE_VIEW_FOLLOW_OFFSET_DEFAULT,
|
||||
SHARE_VIEW_FOLLOW_RANGE_MAX_M,
|
||||
followCameraFor,
|
||||
followOffsetFor,
|
||||
followOffsetFromEnu,
|
||||
followOffsetToEnu,
|
||||
} from './follow.js';
|
||||
@@ -387,8 +389,9 @@ const fieldNumber = (key) => FIELD_BY_KEY.get(key).number;
|
||||
|
||||
/**
|
||||
* Fields inside the thing message (top-level field 13). Permanent. Fields 1–4
|
||||
* are the entity ids, one per SHARE_VIEW_ENTITY_KINDS code; exactly one is
|
||||
* present. The follow offset and the last-known fix are flat fields here, not
|
||||
* are the entity ids, one per SHARE_VIEW_ENTITY_KINDS code, and field 13 is a
|
||||
* vessel id that is a UUID, as its 16 bytes; exactly one of them is present.
|
||||
* The follow offset and the last-known fix are flat fields here, not
|
||||
* sub-messages, to save their tag and length bytes.
|
||||
*/
|
||||
export const SHARE_VIEW_THING_FIELDS = Object.freeze({
|
||||
@@ -400,8 +403,17 @@ export const SHARE_VIEW_THING_FIELDS = Object.freeze({
|
||||
alt: 10, // sint metres, its last-known altitude
|
||||
ageS: 11, // sint, seconds the fix predates the created-at time (field 8)
|
||||
observedAt: 12, // epoch, when the fix was taken; only without a created-at
|
||||
vesselUuid: 13, // LEN, 16 bytes: a vessel id that is a UUID
|
||||
});
|
||||
|
||||
/**
|
||||
* A vessel id that is a UUID, as a source's stable id may be: lowercase,
|
||||
* dashed. It travels as its 16 bytes (thing field 13), not as text.
|
||||
*/
|
||||
export const SHARE_VIEW_UUID_GRAMMAR =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const UUID_BYTES = 16;
|
||||
|
||||
/** The default of a field or flag for a view in `style`. */
|
||||
function defaultOf(entry, style) {
|
||||
if (entry.default !== BY_STYLE) return entry.default;
|
||||
@@ -490,7 +502,10 @@ function normalizeEntityId(entry, value) {
|
||||
: typeof value === 'string'
|
||||
? value.trim().toLowerCase()
|
||||
: null;
|
||||
if (raw === null || !TRACKING_ID_GRAMMAR.test(raw)) return null;
|
||||
if (raw === null) return null;
|
||||
// A vessel's source may key it by a UUID, longer than the id grammar.
|
||||
if (entry.kind === 'vessel' && SHARE_VIEW_UUID_GRAMMAR.test(raw)) return raw;
|
||||
if (!TRACKING_ID_GRAMMAR.test(raw)) return null;
|
||||
if (entry.kind === 'satellite' && !/^[1-9]\d{0,14}$/.test(raw)) return null;
|
||||
return raw;
|
||||
}
|
||||
@@ -795,6 +810,31 @@ const REGISTRY_BY_TOKEN = new Map(
|
||||
const charNumber = (char) => LAYER_STATE_TOKEN_ALPHABET.indexOf(char);
|
||||
const CANONICAL_INTEGER = /^(0|-?[1-9]\d{0,14})$/;
|
||||
|
||||
/** The 16 bytes of a UUID in SHARE_VIEW_UUID_GRAMMAR. */
|
||||
function uuidBytes(id) {
|
||||
const hex = id.replaceAll('-', '');
|
||||
return Array.from({ length: UUID_BYTES }, (_, i) =>
|
||||
Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16),
|
||||
);
|
||||
}
|
||||
|
||||
/** The lowercase dashed UUID that `bytes` spell. */
|
||||
function uuidText(bytes) {
|
||||
const hex = Array.from(bytes, (byte) =>
|
||||
byte.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
return [
|
||||
hex.slice(0, 8),
|
||||
hex.slice(8, 12),
|
||||
hex.slice(12, 16),
|
||||
hex.slice(16, 20),
|
||||
hex.slice(20),
|
||||
].join('-');
|
||||
}
|
||||
|
||||
const isVesselUuid = (entry, id) =>
|
||||
entry.kind === 'vessel' && SHARE_VIEW_UUID_GRAMMAR.test(id);
|
||||
|
||||
function writeEntityId(writer, entry, id) {
|
||||
const field = entry.code;
|
||||
if (entry.idForm === 'hex24' && /^[0-9a-f]{6}$/.test(id))
|
||||
@@ -895,7 +935,8 @@ function encodeThing(writer, view, state) {
|
||||
const f = SHARE_VIEW_THING_FIELDS;
|
||||
const d = SHARE_VIEW_FOLLOW_OFFSET_DEFAULT;
|
||||
const thing = new WireWriter();
|
||||
writeEntityId(thing, entry, view.entity.id);
|
||||
const uuid = isVesselUuid(entry, view.entity.id);
|
||||
if (!uuid) writeEntityId(thing, entry, view.entity.id);
|
||||
const { range, heading, pitch } = view.offset;
|
||||
if (range !== d.range) thing.uintField(f.range, range);
|
||||
if (heading !== d.heading) thing.sintField(f.heading, signedAngle(heading));
|
||||
@@ -914,6 +955,8 @@ function encodeThing(writer, view, state) {
|
||||
} else if (observed !== null)
|
||||
thing.sintField(f.observedAt, epochSeconds(observed));
|
||||
}
|
||||
// Fields go in ascending order, so the UUID id (field 13) comes last.
|
||||
if (uuid) thing.bytesField(f.vesselUuid, uuidBytes(view.entity.id));
|
||||
writer.bytesField(fieldNumber('thing'), thing.finish());
|
||||
}
|
||||
|
||||
@@ -1063,7 +1106,9 @@ const TOP_LEVEL_WIRE_TYPES = new Map(
|
||||
const THING_WIRE_TYPES = new Map([
|
||||
...SHARE_VIEW_ENTITY_KINDS.map((e) => [e.code, [WIRE_VARINT, WIRE_LEN]]),
|
||||
...Object.values(SHARE_VIEW_THING_FIELDS).map((n) => [n, [WIRE_VARINT]]),
|
||||
[SHARE_VIEW_THING_FIELDS.vesselUuid, [WIRE_LEN]],
|
||||
]);
|
||||
const VESSEL_ENTRY = SHARE_VIEW_ENTITY_KINDS.find((e) => e.kind === 'vessel');
|
||||
|
||||
const unz = unzigzag;
|
||||
const CLOSED_ENUMS = new Set(['detectionMode', 'detectionAllocation']);
|
||||
@@ -1258,8 +1303,18 @@ function decodeThing(reader, createdAtS, unknown) {
|
||||
? followCameraFor(lastKnown, normalizeFollowOffset(offset))
|
||||
: null;
|
||||
const present = SHARE_VIEW_ENTITY_KINDS.filter((e) => fields.has(e.code));
|
||||
if (present.length > 1)
|
||||
const uuid = fields.get(F.vesselUuid);
|
||||
if (present.length + (uuid ? 1 : 0) > 1)
|
||||
throw new ShareWireError('duplicate-field', 'thing entity id');
|
||||
if (uuid) {
|
||||
const bytes = uuid.len.rest();
|
||||
if (bytes.length !== UUID_BYTES)
|
||||
throw new ShareWireError('bad-length', 'thing vessel id');
|
||||
return {
|
||||
camera,
|
||||
view: thingView(VESSEL_ENTRY.kind, uuidText(bytes), offset, lastKnown),
|
||||
};
|
||||
}
|
||||
const entry = present[0];
|
||||
if (!entry) {
|
||||
// A newer app's entity kind uses a thing field this decoder skipped.
|
||||
|
||||
@@ -43,14 +43,17 @@ import {
|
||||
BY_STYLE,
|
||||
SHARE_VIEW_FLAGS,
|
||||
SHARE_VIEW_FOLLOW_OFFSET_DEFAULT,
|
||||
SHARE_VIEW_FOLLOW_RANGE_MAX_M,
|
||||
SHARE_VIEW_STYLE_DEFAULTS,
|
||||
SHARE_VIEW_LAYER_OPTION_ABSENT,
|
||||
SHARE_VIEW_PANELS,
|
||||
SHARE_VIEW_STYLE_PARAMS,
|
||||
SHARE_VIEW_THING_FIELDS,
|
||||
createDefaultShareViewState,
|
||||
decodeShareView,
|
||||
encodeShareView,
|
||||
followCameraFor,
|
||||
followOffsetFor,
|
||||
followOffsetFromEnu,
|
||||
followOffsetToEnu,
|
||||
layerTokenFromNumber,
|
||||
@@ -149,6 +152,7 @@ function randomState(random) {
|
||||
String(int(100000000, 999999999)),
|
||||
'002442000',
|
||||
'tmp-7',
|
||||
'0770e3ca-b896-c307-f499-4f9df2b8f86b',
|
||||
])
|
||||
: pick(random, [
|
||||
int(0, 0xffffff).toString(16).padStart(6, '0'),
|
||||
@@ -1359,3 +1363,211 @@ test('the world time is written only away from live, and may lie ahead', () => {
|
||||
encodeShareView({ sharedAtMs: NOW_MS }).length,
|
||||
);
|
||||
});
|
||||
|
||||
/* ---------------- vessel ids that are UUIDs ---------------- */
|
||||
|
||||
const VESSEL_UUID = '0770e3ca-b896-c307-f499-4f9df2b8f86b';
|
||||
const VESSEL_FIX = {
|
||||
lat: 51.9301,
|
||||
lon: 4.0512,
|
||||
alt: 47,
|
||||
observedAtMs: NOW_MS - 30_000,
|
||||
};
|
||||
const VESSEL_OFFSET = { range: 3200, heading: 20, pitch: -30 };
|
||||
const vesselView = (id, extra = {}) => ({
|
||||
view: {
|
||||
kind: 'thing',
|
||||
entity: { kind: 'vessel', id },
|
||||
offset: VESSEL_OFFSET,
|
||||
lastKnown: VESSEL_FIX,
|
||||
},
|
||||
sharedAtMs: NOW_MS,
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** A thing message's bytes, wrapped as a whole payload's code. */
|
||||
function thingCode(write) {
|
||||
const thing = new WireWriter();
|
||||
write(thing);
|
||||
const writer = new WireWriter();
|
||||
writer.bytes.push(1);
|
||||
writer.bytesField(13, thing.finish());
|
||||
return hashOf(writer.finish());
|
||||
}
|
||||
|
||||
test('a vessel id that is a UUID travels as its 16 bytes and round-trips', () => {
|
||||
assert.equal(SHARE_VIEW_THING_FIELDS.vesselUuid, 13);
|
||||
const code = assertRoundTrip(vesselView(VESSEL_UUID));
|
||||
const { state } = decodeOk(code);
|
||||
assert.deepEqual(state.view.entity, { kind: 'vessel', id: VESSEL_UUID });
|
||||
// Upper case is the same id: the decoder gives it back lowercase.
|
||||
assert.equal(
|
||||
decodeOk(encodeShareView(vesselView(VESSEL_UUID.toUpperCase()))).state.view
|
||||
.entity.id,
|
||||
VESSEL_UUID,
|
||||
);
|
||||
// 16 bytes and a two-byte tag and length, not 36 characters of text.
|
||||
const aircraft = encodeShareView({
|
||||
...vesselView('a1b2c3'),
|
||||
view: {
|
||||
...vesselView('a1b2c3').view,
|
||||
entity: { kind: 'aircraft', id: 'a1b2c3' },
|
||||
},
|
||||
});
|
||||
assert.equal(aircraft.length, 44);
|
||||
assert.equal(code.length, 62);
|
||||
assert.equal(
|
||||
payloadBytes(code).length - payloadBytes(aircraft).length,
|
||||
18 - 5,
|
||||
'the id costs 18 bytes where an ICAO address costs 5',
|
||||
);
|
||||
// The id field comes last, after the fix, in ascending order.
|
||||
const bytes = payloadBytes(code);
|
||||
assert.deepEqual(
|
||||
[...bytes.slice(-18)],
|
||||
[
|
||||
0x6a,
|
||||
16,
|
||||
...VESSEL_UUID.replaceAll('-', '')
|
||||
.match(/../g)
|
||||
.map((h) => parseInt(h, 16)),
|
||||
],
|
||||
);
|
||||
// A UUID form is only a vessel's: other kinds keep the id grammar.
|
||||
for (const kind of ['aircraft', 'military_aircraft', 'satellite'])
|
||||
assert.deepEqual(
|
||||
normalizeShareViewState({
|
||||
view: { kind: 'thing', entity: { kind, id: VESSEL_UUID } },
|
||||
}).view,
|
||||
{ kind: 'place' },
|
||||
);
|
||||
// Near-UUIDs are not UUIDs, and are longer than the id grammar allows.
|
||||
for (const id of [VESSEL_UUID.replaceAll('-', '') + '0', `${VESSEL_UUID}0`])
|
||||
assert.deepEqual(
|
||||
normalizeShareViewState({
|
||||
view: { kind: 'thing', entity: { kind: 'vessel', id } },
|
||||
}).view,
|
||||
{ kind: 'place' },
|
||||
);
|
||||
});
|
||||
|
||||
test('MMSI and short text vessel ids, and links made before UUIDs, are unchanged', () => {
|
||||
// Codes written before vessel ids could be UUIDs.
|
||||
const before = {
|
||||
'AUCAn8wYahkYoPH_rgEogBkwKDg7QIqyP0iA-QRQXlg8Ag': '366999712',
|
||||
AUCAn8wYah4aCTAwMjQ0MjAwMCiAGTAoODtAirI_SID5BFBeWDyl: '002442000',
|
||||
};
|
||||
for (const [code, id] of Object.entries(before)) {
|
||||
assert.equal(encodeShareView(vesselView(id)), code);
|
||||
const { state } = decodeOk(code);
|
||||
assert.deepEqual(state.view, {
|
||||
kind: 'thing',
|
||||
entity: { kind: 'vessel', id },
|
||||
offset: VESSEL_OFFSET,
|
||||
lastKnown: VESSEL_FIX,
|
||||
});
|
||||
}
|
||||
// A camera-only vessel view with a short text id, as before.
|
||||
const tmp = 'ARCgnAEYwLgCagcaBXRtcC03GQ';
|
||||
assert.equal(
|
||||
encodeShareView({
|
||||
lat: 1,
|
||||
lon: 2,
|
||||
view: { kind: 'thing', entity: { kind: 'vessel', id: 'tmp-7' } },
|
||||
}),
|
||||
tmp,
|
||||
);
|
||||
assert.equal(decodeOk(tmp).state.view.entity.id, 'tmp-7');
|
||||
});
|
||||
|
||||
test('a UUID vessel id with another id, or of the wrong length, is malformed', () => {
|
||||
const uuid = VESSEL_UUID.replaceAll('-', '')
|
||||
.match(/../g)
|
||||
.map((h) => parseInt(h, 16));
|
||||
const reason = (code) => decodeShareView(code, { nowMs: NOW_MS }).reason;
|
||||
// Two entity ids: the UUID and an MMSI, or an aircraft's address.
|
||||
assert.equal(
|
||||
reason(thingCode((t) => t.uintField(3, 366999712).bytesField(13, uuid))),
|
||||
'duplicate-field',
|
||||
);
|
||||
assert.equal(
|
||||
reason(thingCode((t) => t.uintField(1, 0xa1b2c3).bytesField(13, uuid))),
|
||||
'duplicate-field',
|
||||
);
|
||||
assert.equal(
|
||||
reason(thingCode((t) => t.bytesField(13, uuid).bytesField(13, uuid))),
|
||||
'duplicate-field',
|
||||
);
|
||||
for (const bytes of [uuid.slice(0, 15), [...uuid, 0], []])
|
||||
assert.equal(
|
||||
reason(thingCode((t) => t.bytesField(13, bytes))),
|
||||
'bad-length',
|
||||
);
|
||||
// The field is LEN only.
|
||||
assert.equal(
|
||||
reason(thingCode((t) => t.uintField(13, 5))),
|
||||
'wire-type-mismatch',
|
||||
);
|
||||
// The 16 bytes alone are a vessel; any bytes spell a UUID.
|
||||
assert.deepEqual(
|
||||
decodeOk(thingCode((t) => t.bytesField(13, new Array(16).fill(0xff)))).state
|
||||
.view.entity,
|
||||
{ kind: 'vessel', id: 'ffffffff-ffff-ffff-ffff-ffffffffffff' },
|
||||
);
|
||||
});
|
||||
|
||||
test('a decoder that predates UUID vessel ids opens the view at its derived camera', () => {
|
||||
// Such a decoder skips thing field 13 as it skips any unknown field: the
|
||||
// same payload with the id under a field this decoder does not know.
|
||||
const bytes = [...payloadBytes(encodeShareView(vesselView(VESSEL_UUID)))];
|
||||
const at = bytes.length - 18;
|
||||
assert.equal(bytes[at], 0x6a);
|
||||
bytes[at] = 0xf2; // field 30, LEN
|
||||
bytes.splice(at + 1, 0, 0x01);
|
||||
// The thing message grew by a byte: its own length too.
|
||||
const thingLength = bytes.indexOf(0x6a) + 1;
|
||||
bytes[thingLength] += 1;
|
||||
const result = decodeOk(hashOf(bytes));
|
||||
assert.equal(result.fallback, 'unknown-entity-kind');
|
||||
assert.deepEqual(result.state.view, { kind: 'place' });
|
||||
const camera = followCameraFor(VESSEL_FIX, VESSEL_OFFSET);
|
||||
assert.equal(result.state.lat, Math.round(camera.lat * 1e4) / 1e4);
|
||||
assert.equal(result.state.lon, Math.round(camera.lon * 1e4) / 1e4);
|
||||
});
|
||||
|
||||
test('the follow offset for a camera is the inverse of followCameraFor', () => {
|
||||
const fix = { lat: 51.9301, lon: 4.0512, alt: 47 };
|
||||
for (const offset of [
|
||||
{ range: 3200, heading: 20, pitch: -30 },
|
||||
{ range: 250, heading: 300, pitch: -5 },
|
||||
{ range: 80_000, heading: 135, pitch: -70 },
|
||||
{ range: 2_000_000, heading: 0, pitch: -89 },
|
||||
])
|
||||
assert.deepEqual(
|
||||
followOffsetFor(fix, followCameraFor(fix, offset)),
|
||||
offset,
|
||||
);
|
||||
// The codec carries ranges up to the bound a subject is checked against.
|
||||
const far = {
|
||||
range: SHARE_VIEW_FOLLOW_RANGE_MAX_M + 5,
|
||||
heading: 0,
|
||||
pitch: -35,
|
||||
};
|
||||
assert.equal(
|
||||
normalizeShareViewState({
|
||||
view: { kind: 'thing', entity: { kind: 'vessel', id: '1' }, offset: far },
|
||||
}).view.offset.range,
|
||||
SHARE_VIEW_FOLLOW_RANGE_MAX_M,
|
||||
);
|
||||
// A camera below the entity's horizontal, or at it, has no offset.
|
||||
assert.equal(
|
||||
followOffsetFor(fix, { lat: 51.95, lon: 4.0512, alt: 40 }),
|
||||
null,
|
||||
);
|
||||
assert.equal(followOffsetFor(fix, fix), null);
|
||||
// Far round the curve of the earth at low height: below its horizon.
|
||||
assert.equal(
|
||||
followOffsetFor(fix, { lat: 53.9, lon: 4.0512, alt: 1000 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -8,6 +8,20 @@
|
||||
* getTrack(reference, { signal }). References are opaque to consumers.
|
||||
* Snapshots describe coverage and completeness independently of freshness.
|
||||
* Records contain observation data only, with no scene objects or transport data.
|
||||
* Every record has a stable `id` (a non-empty string) that keys it across
|
||||
* snapshots. A vessel record's `id` is whatever its source gives, such as a
|
||||
* provider id; its `mmsi` is an optional attribute ('' or absent when
|
||||
* unknown), never its key, since vessels can share an MMSI. A source that
|
||||
* only has MMSIs uses the MMSI as the id.
|
||||
*
|
||||
* A source may also declare a timeline (see readSnapshotTimeline): it can
|
||||
* answer a snapshot at a given time, so the layer follows the world clock.
|
||||
* The query then carries `atMs` (absent while the clock is live, so a live
|
||||
* request is unchanged), and the snapshot is the world as it stood at that
|
||||
* time: `observedAtMs` <= `atMs`, no record observed later than `atMs`,
|
||||
* freshness and staleness judged against `atMs`, and `atMs` echoed back. A
|
||||
* vessel snapshot at a time reports `transportStatus: 'live'`. getTrack then
|
||||
* receives `{ signal, atMs }` and returns only points at or before `atMs`.
|
||||
*/
|
||||
// Messages are English data (AI tools read them too); the Layers panel shows
|
||||
// them translated by their shape (translateFeedError in src/ui/statusText.js),
|
||||
@@ -140,3 +154,32 @@ export function httpError(response, source) {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The timeline a source declares, or null when it has none or it is
|
||||
* malformed: `{ coverage(), stepMs, subscribe? }`. `coverage()` returns the
|
||||
* span `{start, end}` (epoch ms) the source can answer a snapshot for, or
|
||||
* null while it does not know yet; `stepMs` is the spacing of its snapshots;
|
||||
* the optional `subscribe(listener)` tells of a change in coverage, or of a
|
||||
* better answer for a time already answered (a paused layer then loads the
|
||||
* time shown again), and returns the unsubscribe.
|
||||
* @param {object|null|undefined} source
|
||||
* @returns {{coverage: () => ({start: number, end: number}|null),
|
||||
* stepMs: number, subscribe: ((listener: () => void) => () => void)|null}|null}
|
||||
*/
|
||||
export function readSnapshotTimeline(source) {
|
||||
const timeline = source?.timeline;
|
||||
if (!timeline || typeof timeline !== 'object') return null;
|
||||
if (typeof timeline.coverage !== 'function') return null;
|
||||
const stepMs = timeline.stepMs;
|
||||
if (!Number.isFinite(stepMs) || stepMs <= 0) return null;
|
||||
if (timeline.subscribe != null && typeof timeline.subscribe !== 'function')
|
||||
return null;
|
||||
return {
|
||||
coverage: () => timeline.coverage(),
|
||||
stepMs,
|
||||
subscribe: timeline.subscribe
|
||||
? (listener) => timeline.subscribe(listener)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
normalizeVesselObservation,
|
||||
openSkySnapshot,
|
||||
readsbSnapshot,
|
||||
readSnapshotTimeline,
|
||||
vesselSnapshot,
|
||||
} from './index.js';
|
||||
|
||||
const now = 1800000000000;
|
||||
@@ -195,6 +197,7 @@ test('vessel identity, opaque reference and heading/course remain distinct, unkn
|
||||
'opaque-reference',
|
||||
);
|
||||
assert.equal(row.id, '123456789');
|
||||
assert.equal(row.mmsi, '123456789');
|
||||
assert.equal(row.reference, 'opaque-reference');
|
||||
assert.equal(row.headingDeg, 45);
|
||||
assert.equal(row.courseDeg, 60);
|
||||
@@ -486,3 +489,100 @@ test('a photo enrichment outage rejects without echoing the body', async () => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a source declares a timeline only with a coverage reader and a positive step', () => {
|
||||
assert.equal(readSnapshotTimeline(null), null);
|
||||
assert.equal(readSnapshotTimeline({ getSnapshot() {} }), null);
|
||||
assert.equal(readSnapshotTimeline({ timeline: { stepMs: 10_000 } }), null);
|
||||
assert.equal(
|
||||
readSnapshotTimeline({ timeline: { coverage: () => null, stepMs: 0 } }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
readSnapshotTimeline({
|
||||
timeline: { coverage: () => null, stepMs: 10_000, subscribe: 'no' },
|
||||
}),
|
||||
null,
|
||||
);
|
||||
const span = { start: 1000, end: 5000 };
|
||||
const plain = readSnapshotTimeline({
|
||||
timeline: { coverage: () => span, stepMs: 10_000 },
|
||||
});
|
||||
assert.equal(plain.stepMs, 10_000);
|
||||
assert.deepEqual(plain.coverage(), span);
|
||||
assert.equal(plain.subscribe, null);
|
||||
const listeners = [];
|
||||
const told = readSnapshotTimeline({
|
||||
timeline: {
|
||||
coverage: () => null,
|
||||
stepMs: 5_000,
|
||||
subscribe(listener) {
|
||||
listeners.push(listener);
|
||||
return () => listeners.splice(listeners.indexOf(listener), 1);
|
||||
},
|
||||
},
|
||||
});
|
||||
const stop = told.subscribe(() => {});
|
||||
assert.equal(listeners.length, 1);
|
||||
stop();
|
||||
assert.equal(listeners.length, 0);
|
||||
});
|
||||
|
||||
test('vessel snapshots key rows by the id idFor gives; the MMSI is an attribute', () => {
|
||||
const at = { lat: 30, lon: -97 };
|
||||
const rows = [
|
||||
{ uuid: 'hull-a', mmsi: '123456789', ...at },
|
||||
{ uuid: 'hull-b', mmsi: '123456789', ...at },
|
||||
{ uuid: 'hull-c', mmsi: 'not-an-mmsi', ...at },
|
||||
{ uuid: 'hull-a', mmsi: '123456789', ...at },
|
||||
];
|
||||
const keyed = vesselSnapshot(
|
||||
{ rows },
|
||||
{ idFor: (row) => row.uuid || row.mmsi },
|
||||
);
|
||||
assert.deepEqual(
|
||||
keyed.records.map(({ id, mmsi, reference }) => [id, mmsi, reference]),
|
||||
[
|
||||
['hull-a', '123456789', 'hull-a'],
|
||||
['hull-b', '123456789', 'hull-b'],
|
||||
['hull-c', '', 'hull-c'],
|
||||
],
|
||||
);
|
||||
assert.equal(keyed.rejectedCount, 1, 'a repeated id is one vessel');
|
||||
|
||||
// AISStream rows have only MMSIs: one MMSI is one vessel, as before.
|
||||
const ais = vesselSnapshot({ rows });
|
||||
assert.deepEqual(
|
||||
ais.records.map(({ id, mmsi, reference }) => [id, mmsi, reference]),
|
||||
[
|
||||
['123456789', '123456789', '123456789'],
|
||||
['not-an-mmsi', '', 'not-an-mmsi'],
|
||||
],
|
||||
);
|
||||
const referenced = vesselSnapshot(
|
||||
{ rows: rows.slice(0, 1) },
|
||||
{ idFor: (row) => row.uuid, referenceFor: (row) => `track:${row.uuid}` },
|
||||
);
|
||||
assert.equal(referenced.records[0].reference, 'track:hull-a');
|
||||
});
|
||||
|
||||
test('a vessel with no name is not named by its id; a ten-digit MMSI is an MMSI', () => {
|
||||
const at = { lat: 30, lon: -97 };
|
||||
const [unnamed, longMmsi, named] = vesselSnapshot(
|
||||
{
|
||||
rows: [
|
||||
{ uuid: 'f00dcafe-0000-0000-0000-000000000001', ...at },
|
||||
{ uuid: 'hull-x', mmsi: '1234567890', ...at },
|
||||
{ uuid: 'hull-y', mmsi: '244123456', name: 'EVER GIVEN', ...at },
|
||||
],
|
||||
},
|
||||
{ idFor: (row) => row.uuid },
|
||||
).records;
|
||||
assert.equal(unnamed.name, '');
|
||||
assert.equal(unnamed.mmsi, '');
|
||||
assert.deepEqual(
|
||||
[longMmsi.mmsi, longMmsi.name],
|
||||
['1234567890', '1234567890'],
|
||||
);
|
||||
assert.equal(named.name, 'EVER GIVEN');
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
export { LiveSourceError } from './contract.js';
|
||||
export { LiveSourceError, readSnapshotTimeline } from './contract.js';
|
||||
export {
|
||||
normalizeOpenSkyAircraft,
|
||||
normalizeReadsbAircraft,
|
||||
|
||||
+47
-10
@@ -1,18 +1,41 @@
|
||||
import { coordinates, epoch, finite, LiveSourceError } from './contract.js';
|
||||
|
||||
/** AIS positions are sea-surface observations; heading and course are distinct. */
|
||||
export function normalizeVesselObservation(row, reference = null) {
|
||||
const id = String(row?.mmsi || row?.input_identifier || '').trim();
|
||||
/** An MMSI is up to ten digits (AIS carries it in 30 bits); anything else a
|
||||
* row names it is not one. */
|
||||
const MMSI_PATTERN = /^\d{1,10}$/;
|
||||
|
||||
/** The row's MMSI when it carries one that looks like an MMSI, else ''. */
|
||||
function vesselMmsi(row) {
|
||||
const text = String(row?.mmsi ?? '').trim();
|
||||
return MMSI_PATTERN.test(text) ? text : '';
|
||||
}
|
||||
|
||||
/** A row's stable id when its source names none: its MMSI or identifier. */
|
||||
function defaultVesselId(row) {
|
||||
return String(row?.mmsi || row?.input_identifier || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* AIS positions are sea-surface observations; heading and course are distinct.
|
||||
* `id` is the vessel's key; an MMSI is not one (vessels share numbers and a
|
||||
* number can move to another hull), so `mmsi` is an attribute, '' when the
|
||||
* row has none. A row with no id of its own is keyed by its MMSI.
|
||||
*/
|
||||
export function normalizeVesselObservation(row, reference = null, id = null) {
|
||||
const key = String(id ?? defaultVesselId(row)).trim();
|
||||
const latitude = finite(row?.lat),
|
||||
longitude = finite(row?.lon);
|
||||
if (!id || !coordinates(latitude, longitude)) return null;
|
||||
if (!key || !coordinates(latitude, longitude)) return null;
|
||||
const mmsi = vesselMmsi(row);
|
||||
const speedKts = finite(row.speed);
|
||||
return {
|
||||
id,
|
||||
reference: reference ?? id,
|
||||
id: key,
|
||||
mmsi,
|
||||
reference: reference ?? key,
|
||||
latitude,
|
||||
longitude,
|
||||
name: String(row.name || row.input_name || id),
|
||||
// A source's own id is not a name: a vessel with none is unnamed.
|
||||
name: String(row.name || row.input_name || mmsi || defaultVesselId(row)),
|
||||
imo: String(row.imo || ''),
|
||||
type: String(row.type_specific || row.type || ''),
|
||||
destination: String(row.destination || ''),
|
||||
@@ -26,12 +49,21 @@ export function normalizeVesselObservation(row, reference = null) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A vessel snapshot from a `{ rows }` payload. `idFor(row)` gives a row's
|
||||
* stable id (its MMSI or identifier by default); rows are deduped by that id,
|
||||
* so two vessels sharing an MMSI stay two vessels. A source whose rows carry
|
||||
* an id of their own passes it, such as `idFor: (row) => row.uuid || row.mmsi`.
|
||||
* `referenceFor(row)` gives the reference its track is asked for by; the id
|
||||
* by default.
|
||||
*/
|
||||
export function vesselSnapshot(
|
||||
payload,
|
||||
{
|
||||
source = 'AISStream',
|
||||
coverage = 'received AIS positions',
|
||||
referenceFor = (row) => String(row.mmsi || row.input_identifier || ''),
|
||||
idFor = defaultVesselId,
|
||||
referenceFor = null,
|
||||
} = {},
|
||||
) {
|
||||
if (!Array.isArray(payload?.rows))
|
||||
@@ -40,8 +72,13 @@ export function vesselSnapshot(
|
||||
const records = [],
|
||||
ids = new Set();
|
||||
for (const row of rows) {
|
||||
const record = normalizeVesselObservation(row);
|
||||
if (record) record.reference = referenceFor(row);
|
||||
const record = normalizeVesselObservation(
|
||||
row,
|
||||
null,
|
||||
row && typeof row === 'object' ? idFor(row) : null,
|
||||
);
|
||||
// A row's track is asked for by its id unless the source says otherwise.
|
||||
if (record && referenceFor) record.reference = referenceFor(row);
|
||||
if (record && !ids.has(record.id)) {
|
||||
records.push(record);
|
||||
ids.add(record.id);
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { readSnapshotTimeline } from '../sources/live/contract.js';
|
||||
|
||||
/** The longest play waits on a snapshot before it goes on without it. */
|
||||
export const SNAPSHOT_WAIT_MS = 8_000;
|
||||
/** How long a snapshot that failed to load waits before it is asked again. */
|
||||
export const SNAPSHOT_RETRY_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Let a layer of live contacts (flights, military aircraft, vessels) follow
|
||||
* the world clock when, and only when, its source declares a timeline: it can
|
||||
* answer a snapshot at a given time (see readSnapshotTimeline). With no
|
||||
* timeline this does nothing at all: the layer never follows the clock and
|
||||
* stays live.
|
||||
*
|
||||
* While the layer is on, it follows the clock. At the live edge the layer
|
||||
* polls as it always has. Away from it the layer is `active()`: it stops
|
||||
* polling and shows the snapshots this asks for.
|
||||
*
|
||||
* - A jump (a seek, time going backwards, leaving live) drops what loads,
|
||||
* holds play, and asks for the snapshot at the step at or before the time;
|
||||
* `load(atMs, {signal, replace: true})` puts it in place of every contact.
|
||||
* - Playing on, it stays a snapshot ahead of the time: it asks for the next
|
||||
* step as the clock nears the newest it has, and play does not pass a
|
||||
* snapshot still loading (for at most SNAPSHOT_WAIT_MS). Those snapshots
|
||||
* are applied as polls are (`replace: false`), so contacts move on smoothly.
|
||||
* - Outside the source's coverage the layer is `clear()`ed and nothing loads;
|
||||
* while the coverage is not known yet nothing loads and nothing is cleared.
|
||||
* - Back at live, the layer is `clear()`ed and `resumeLive()` polls at once.
|
||||
*
|
||||
* `load` resolves true once the snapshot is applied and false (or rejects)
|
||||
* when it could not be; it must not apply anything once `signal` aborts. A
|
||||
* rejection named AbortError is a request the source dropped for a newer
|
||||
* one: it is not a failure, and nothing is cleared.
|
||||
*
|
||||
* @param {{id: string, clock: ReturnType<typeof import('./worldClock.js').createWorldClock>,
|
||||
* source: () => object|null,
|
||||
* load: (atMs: number, options: {signal: AbortSignal, replace: boolean}) => Promise<boolean>,
|
||||
* clear: () => void, resumeLive: () => void,
|
||||
* now?: () => number, setTimeout?: typeof globalThis.setTimeout,
|
||||
* clearTimeout?: typeof globalThis.clearTimeout}} options `source`
|
||||
* returns the layer's current source; it is read when the layer starts.
|
||||
*/
|
||||
export function createSnapshotTimeline({
|
||||
id,
|
||||
clock,
|
||||
source,
|
||||
load,
|
||||
clear,
|
||||
resumeLive,
|
||||
now = () => globalThis.performance.now(),
|
||||
setTimeout = globalThis.setTimeout,
|
||||
clearTimeout = globalThis.clearTimeout,
|
||||
}) {
|
||||
let timeline = null;
|
||||
let releases = [];
|
||||
let started = false;
|
||||
// Whether the layer was away from live when it last heard of the clock.
|
||||
let wasActive = false;
|
||||
let lastJumps = null;
|
||||
let lastTimeMs = null;
|
||||
// Each request's number; an answer to an older one is dropped.
|
||||
let sequence = 0;
|
||||
let inflight = null;
|
||||
// The time of the newest snapshot applied since the last jump.
|
||||
let lead = null;
|
||||
// Cleared for a time outside coverage, so it is not cleared again.
|
||||
let empty = false;
|
||||
let waiting = null;
|
||||
let waitTimer = null;
|
||||
// Play waited its longest on the snapshot loading: it goes on without it.
|
||||
let capped = false;
|
||||
let gated;
|
||||
let failedUntil = -Infinity;
|
||||
let retryTimer = null;
|
||||
|
||||
const span = () => {
|
||||
let covered;
|
||||
try {
|
||||
covered = timeline.coverage();
|
||||
} catch (error) {
|
||||
console.error(`Snapshot coverage failed: ${id}`, error);
|
||||
return null;
|
||||
}
|
||||
return covered &&
|
||||
Number.isFinite(covered.start) &&
|
||||
Number.isFinite(covered.end) &&
|
||||
covered.end >= covered.start
|
||||
? covered
|
||||
: null;
|
||||
};
|
||||
const within = (timeMs, covered) =>
|
||||
timeMs >= covered.start && timeMs <= covered.end;
|
||||
const stepAtOrBefore = (timeMs) =>
|
||||
Math.floor(timeMs / timeline.stepMs) * timeline.stepMs;
|
||||
const playsOn = (state) => state.playing && !state.stepping;
|
||||
|
||||
const setGate = (ms) => {
|
||||
if (ms === gated) return;
|
||||
gated = ms;
|
||||
clock.gate(id, ms);
|
||||
};
|
||||
const stopWaiting = () => {
|
||||
const release = waiting;
|
||||
waiting = null;
|
||||
if (waitTimer !== null) clearTimeout(waitTimer);
|
||||
waitTimer = null;
|
||||
release?.();
|
||||
};
|
||||
const startWaiting = () => {
|
||||
if (waiting || capped) return;
|
||||
waiting = clock.wait(id);
|
||||
waitTimer = setTimeout(() => {
|
||||
waitTimer = null;
|
||||
capped = true;
|
||||
stopWaiting();
|
||||
setGate(null);
|
||||
}, SNAPSHOT_WAIT_MS);
|
||||
};
|
||||
const stopRetry = () => {
|
||||
if (retryTimer !== null) clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
};
|
||||
// Drop what loads; nothing that answers afterwards applies.
|
||||
const cancel = () => {
|
||||
sequence += 1;
|
||||
inflight?.controller.abort();
|
||||
inflight = null;
|
||||
capped = false;
|
||||
stopWaiting();
|
||||
stopRetry();
|
||||
};
|
||||
const clearOnce = () => {
|
||||
if (empty) return;
|
||||
empty = true;
|
||||
clear();
|
||||
};
|
||||
|
||||
function request(atMs, replace) {
|
||||
// One load at a time: an older one is dropped before a newer starts.
|
||||
inflight?.controller.abort();
|
||||
const mine = ++sequence;
|
||||
const controller = new AbortController();
|
||||
inflight = { atMs, controller };
|
||||
const failed = () => {
|
||||
inflight = null;
|
||||
// A time that could not be shown shows nothing, not another time.
|
||||
if (replace) clearOnce();
|
||||
capped = false;
|
||||
stopWaiting();
|
||||
failedUntil = now() + SNAPSHOT_RETRY_MS;
|
||||
stopRetry();
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
if (started) follow(clock.getState());
|
||||
}, SNAPSHOT_RETRY_MS);
|
||||
};
|
||||
// The source dropped this request for a newer one of its own: nothing
|
||||
// failed, so nothing is cleared or held back; the time is asked for again
|
||||
// as the clock next moves, or shortly.
|
||||
const superseded = () => {
|
||||
inflight = null;
|
||||
stopRetry();
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null;
|
||||
if (started) follow(clock.getState());
|
||||
}, SNAPSHOT_RETRY_MS);
|
||||
};
|
||||
Promise.resolve()
|
||||
.then(() => load(atMs, { signal: controller.signal, replace }))
|
||||
.then(
|
||||
(applied) => {
|
||||
if (mine !== sequence || controller.signal.aborted) return;
|
||||
if (applied === false) {
|
||||
failed();
|
||||
return;
|
||||
}
|
||||
inflight = null;
|
||||
lead = atMs;
|
||||
empty = false;
|
||||
capped = false;
|
||||
stopWaiting();
|
||||
follow(clock.getState());
|
||||
},
|
||||
(error) => {
|
||||
if (mine !== sequence || controller.signal.aborted) return;
|
||||
if (error?.name === 'AbortError') {
|
||||
superseded();
|
||||
return;
|
||||
}
|
||||
console.warn(`Snapshot at ${atMs} failed: ${id}`, error);
|
||||
failed();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Show the time afresh: the snapshot at its step replaces every contact.
|
||||
function jumpTo(timeMs) {
|
||||
cancel();
|
||||
lead = null;
|
||||
setGate(null);
|
||||
const covered = span();
|
||||
if (!covered) return;
|
||||
if (!within(timeMs, covered)) {
|
||||
clearOnce();
|
||||
return;
|
||||
}
|
||||
startWaiting();
|
||||
request(Math.max(covered.start, stepAtOrBefore(timeMs)), true);
|
||||
}
|
||||
|
||||
// The time moved on by itself, or nothing moved: load what it now needs.
|
||||
function keepUp(timeMs, state) {
|
||||
const covered = span();
|
||||
if (!covered) return;
|
||||
if (!within(timeMs, covered)) {
|
||||
cancel();
|
||||
lead = null;
|
||||
setGate(null);
|
||||
clearOnce();
|
||||
return;
|
||||
}
|
||||
if (inflight) {
|
||||
if (playsOn(state) && timeMs >= inflight.atMs) startWaiting();
|
||||
return;
|
||||
}
|
||||
if (now() < failedUntil) return;
|
||||
if (lead === null) {
|
||||
jumpTo(timeMs);
|
||||
return;
|
||||
}
|
||||
if (!playsOn(state)) {
|
||||
setGate(null);
|
||||
return;
|
||||
}
|
||||
const step = timeline.stepMs;
|
||||
if (lead - timeMs >= step) {
|
||||
setGate(lead);
|
||||
return;
|
||||
}
|
||||
const next = Math.min(
|
||||
covered.end,
|
||||
Math.max(lead + step, stepAtOrBefore(timeMs) + step),
|
||||
);
|
||||
if (next <= lead) {
|
||||
setGate(null);
|
||||
return;
|
||||
}
|
||||
setGate(capped ? null : next);
|
||||
request(next, false);
|
||||
}
|
||||
|
||||
function follow(state) {
|
||||
if (!started) return;
|
||||
if (state.live) {
|
||||
lastJumps = state.jumps;
|
||||
lastTimeMs = null;
|
||||
if (!wasActive) return;
|
||||
wasActive = false;
|
||||
cancel();
|
||||
setGate(null);
|
||||
lead = null;
|
||||
empty = false;
|
||||
failedUntil = -Infinity;
|
||||
clear();
|
||||
resumeLive();
|
||||
return;
|
||||
}
|
||||
const timeMs = state.timeMs;
|
||||
const jumped =
|
||||
!wasActive ||
|
||||
state.jumps !== lastJumps ||
|
||||
(lastTimeMs !== null && timeMs < lastTimeMs);
|
||||
wasActive = true;
|
||||
lastJumps = state.jumps;
|
||||
lastTimeMs = timeMs;
|
||||
if (jumped) {
|
||||
failedUntil = -Infinity;
|
||||
jumpTo(timeMs);
|
||||
} else keepUp(timeMs, state);
|
||||
}
|
||||
|
||||
/** Load the time shown afresh. */
|
||||
function reloadShown() {
|
||||
if (!started || clock.isLive()) return;
|
||||
failedUntil = -Infinity;
|
||||
jumpTo(clock.getState().timeMs);
|
||||
}
|
||||
|
||||
return {
|
||||
/** The source's timeline while the layer follows the clock, else null. */
|
||||
get timeline() {
|
||||
return started ? timeline : null;
|
||||
},
|
||||
/** Whether the layer shows the snapshots this loads instead of polling. */
|
||||
active: () => started && !clock.isLive(),
|
||||
/** The layer is on: follow the clock if its source declares a timeline. */
|
||||
start() {
|
||||
if (started) return;
|
||||
timeline = readSnapshotTimeline(source());
|
||||
if (!timeline) return;
|
||||
started = true;
|
||||
wasActive = false;
|
||||
lastJumps = null;
|
||||
lastTimeMs = null;
|
||||
lead = null;
|
||||
empty = false;
|
||||
failedUntil = -Infinity;
|
||||
gated = undefined;
|
||||
releases = [
|
||||
clock.hold(id),
|
||||
clock.register({ id, coverage: () => span() }),
|
||||
clock.subscribe(follow),
|
||||
];
|
||||
// A source tells of new coverage, or of a better answer for times it
|
||||
// has answered; paused, a time already shown is loaded again.
|
||||
const unsubscribe = timeline.subscribe?.(() => {
|
||||
clock.coverageChanged();
|
||||
if (!clock.isPlaying() && lead !== null && !inflight) reloadShown();
|
||||
});
|
||||
if (typeof unsubscribe === 'function') releases.push(unsubscribe);
|
||||
follow(clock.getState());
|
||||
},
|
||||
/** Load the time shown afresh, as when the area the layer asks for moved. */
|
||||
reload: () => reloadShown(),
|
||||
/** The layer is off: stop following and drop what loads. */
|
||||
stop() {
|
||||
if (!started) return;
|
||||
started = false;
|
||||
cancel();
|
||||
setGate(null);
|
||||
wasActive = false;
|
||||
lead = null;
|
||||
for (const release of releases.splice(0).reverse()) release();
|
||||
timeline = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createWorldClock } from './worldClock.js';
|
||||
import {
|
||||
createSnapshotTimeline,
|
||||
SNAPSHOT_RETRY_MS,
|
||||
SNAPSHOT_WAIT_MS,
|
||||
} from './snapshotTimeline.js';
|
||||
|
||||
const STEP = 10_000;
|
||||
const WALL = Date.UTC(2026, 10, 1, 12);
|
||||
|
||||
function harness({ timeline = true, coverage } = {}) {
|
||||
const t = {
|
||||
wall: WALL,
|
||||
mono: 1_000,
|
||||
timers: new Map(),
|
||||
nextId: 1,
|
||||
};
|
||||
const clock = createWorldClock({
|
||||
now: () => t.wall,
|
||||
monotonicNow: () => t.mono,
|
||||
setInterval: () => 0,
|
||||
clearInterval: () => {},
|
||||
requestFrame: null,
|
||||
cancelFrame: null,
|
||||
});
|
||||
let span =
|
||||
coverage === undefined ? { start: WALL - 3_600_000, end: WALL } : coverage;
|
||||
const coverageListeners = new Set();
|
||||
const source = {
|
||||
getSnapshot() {},
|
||||
...(timeline && {
|
||||
timeline: {
|
||||
coverage: () => span,
|
||||
stepMs: STEP,
|
||||
subscribe(listener) {
|
||||
coverageListeners.add(listener);
|
||||
return () => coverageListeners.delete(listener);
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
const calls = [];
|
||||
const pending = [];
|
||||
const timers = {
|
||||
setTimeout: (fn, ms) => {
|
||||
const id = t.nextId++;
|
||||
t.timers.set(id, { fn, at: t.mono + ms });
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id) => t.timers.delete(id),
|
||||
};
|
||||
const follower = createSnapshotTimeline({
|
||||
id: 'flights',
|
||||
clock,
|
||||
source: () => source,
|
||||
load: (atMs, { signal, replace }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
calls.push({ type: 'load', atMs, replace });
|
||||
pending.push({ atMs, replace, signal, resolve, reject });
|
||||
}),
|
||||
clear: () => calls.push({ type: 'clear' }),
|
||||
resumeLive: () => calls.push({ type: 'live' }),
|
||||
now: () => t.mono,
|
||||
...timers,
|
||||
});
|
||||
return {
|
||||
t,
|
||||
clock,
|
||||
follower,
|
||||
calls,
|
||||
pending,
|
||||
setCoverage(next) {
|
||||
span = next;
|
||||
for (const listener of coverageListeners) listener();
|
||||
},
|
||||
coverageListeners,
|
||||
advance(ms) {
|
||||
t.wall += ms;
|
||||
t.mono += ms;
|
||||
for (const [id, timer] of [...t.timers]) {
|
||||
if (timer.at > t.mono) continue;
|
||||
t.timers.delete(id);
|
||||
timer.fn();
|
||||
}
|
||||
},
|
||||
async answer(index = 0, value = true) {
|
||||
pending.splice(index, 1)[0].resolve(value);
|
||||
await flush();
|
||||
},
|
||||
};
|
||||
}
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
const loads = (calls) => calls.filter((call) => call.type === 'load');
|
||||
|
||||
test('a source with no timeline never follows the clock nor asks for a time', async () => {
|
||||
const h = harness({ timeline: false });
|
||||
h.follower.start();
|
||||
assert.equal(h.follower.timeline, null);
|
||||
assert.equal(h.clock.follows('flights'), false);
|
||||
assert.equal(h.clock.coverage(), null);
|
||||
h.clock.seek(WALL - 600_000);
|
||||
h.clock.play();
|
||||
h.advance(30_000);
|
||||
h.clock.goLive();
|
||||
await flush();
|
||||
assert.equal(h.follower.active(), false);
|
||||
assert.deepEqual(h.calls, []);
|
||||
h.follower.stop();
|
||||
});
|
||||
|
||||
test('at the live edge a layer with a timeline only follows and registers', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
await flush();
|
||||
assert.equal(h.clock.follows('flights'), true);
|
||||
assert.deepEqual(h.clock.coverage(), {
|
||||
start: WALL - 3_600_000,
|
||||
end: WALL,
|
||||
marks: [],
|
||||
});
|
||||
assert.equal(h.follower.active(), false);
|
||||
assert.deepEqual(h.calls, []);
|
||||
h.follower.stop();
|
||||
assert.equal(h.clock.follows('flights'), false);
|
||||
assert.equal(h.clock.coverage(), null);
|
||||
});
|
||||
|
||||
test('a seek asks for the step at or before the time, holding play while it loads', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
const at = WALL - 600_000 + 4_321;
|
||||
h.clock.seek(at);
|
||||
await flush();
|
||||
assert.equal(h.follower.active(), true);
|
||||
assert.deepEqual(loads(h.calls), [
|
||||
{ type: 'load', atMs: WALL - 600_000, replace: true },
|
||||
]);
|
||||
h.clock.play();
|
||||
h.advance(2_000);
|
||||
assert.equal(h.clock.now(), at, 'play waits on the snapshot');
|
||||
assert.equal(h.clock.getState().buffering, true);
|
||||
await h.answer();
|
||||
assert.equal(h.clock.getState().buffering, false);
|
||||
h.follower.stop();
|
||||
});
|
||||
|
||||
test('a seek back replaces the contacts and an older answer is dropped', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
h.clock.seek(WALL - 600_000);
|
||||
await flush();
|
||||
const first = h.pending[0];
|
||||
h.clock.seek(WALL - 1_200_000);
|
||||
await flush();
|
||||
assert.equal(first.signal.aborted, true, 'the older load is cancelled');
|
||||
first.resolve(true);
|
||||
await flush();
|
||||
assert.deepEqual(
|
||||
loads(h.calls).map(({ atMs, replace }) => [atMs, replace]),
|
||||
[
|
||||
[WALL - 600_000, true],
|
||||
[WALL - 1_200_000, true],
|
||||
],
|
||||
);
|
||||
await h.answer(1);
|
||||
// Going backwards in time is a jump too, even without a seek.
|
||||
h.clock.seek(WALL - 1_300_000);
|
||||
await flush();
|
||||
assert.equal(loads(h.calls).at(-1).replace, true);
|
||||
h.follower.stop();
|
||||
});
|
||||
|
||||
test('playing on asks for the next step and play does not pass it unloaded', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
const at = WALL - 600_000;
|
||||
h.clock.seek(at);
|
||||
await flush();
|
||||
await h.answer();
|
||||
h.clock.play();
|
||||
h.advance(100);
|
||||
// A tick: the clock tells its listeners as play goes on.
|
||||
h.clock.coverageChanged();
|
||||
await flush();
|
||||
assert.deepEqual(loads(h.calls).at(-1), {
|
||||
type: 'load',
|
||||
atMs: at + STEP,
|
||||
replace: false,
|
||||
});
|
||||
h.advance(STEP * 2);
|
||||
assert.equal(h.clock.now(), at + STEP, 'gated at the step still loading');
|
||||
h.clock.coverageChanged();
|
||||
assert.equal(h.clock.getState().buffering, true);
|
||||
await h.answer(h.pending.length - 1);
|
||||
assert.equal(h.clock.getState().buffering, false);
|
||||
assert.deepEqual(loads(h.calls).at(-1), {
|
||||
type: 'load',
|
||||
atMs: at + 2 * STEP,
|
||||
replace: false,
|
||||
});
|
||||
// A step that never comes holds play for at most SNAPSHOT_WAIT_MS.
|
||||
h.advance(STEP * 2);
|
||||
h.clock.coverageChanged();
|
||||
assert.equal(h.clock.getState().buffering, true);
|
||||
h.advance(SNAPSHOT_WAIT_MS);
|
||||
assert.equal(h.clock.getState().buffering, false);
|
||||
h.advance(1_000);
|
||||
assert.ok(h.clock.now() > at + 2 * STEP, 'play went on without it');
|
||||
h.follower.stop();
|
||||
});
|
||||
|
||||
test('a failed snapshot clears the time and is asked again later', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
h.clock.seek(WALL - 600_000);
|
||||
await flush();
|
||||
await h.answer(0, false);
|
||||
assert.deepEqual(h.calls.at(-1), { type: 'clear' });
|
||||
assert.equal(loads(h.calls).length, 1);
|
||||
h.advance(SNAPSHOT_RETRY_MS);
|
||||
await flush();
|
||||
assert.equal(loads(h.calls).length, 2);
|
||||
assert.equal(loads(h.calls)[1].replace, true);
|
||||
h.follower.stop();
|
||||
});
|
||||
|
||||
test('outside coverage the layer is cleared and nothing loads; unknown coverage waits', async () => {
|
||||
const h = harness({ coverage: null });
|
||||
h.follower.start();
|
||||
h.clock.seek(WALL - 600_000);
|
||||
await flush();
|
||||
assert.deepEqual(h.calls, [], 'no coverage yet: nothing loads or clears');
|
||||
h.setCoverage({ start: WALL - 3_600_000, end: WALL });
|
||||
await flush();
|
||||
assert.deepEqual(loads(h.calls), [
|
||||
{ type: 'load', atMs: WALL - 600_000, replace: true },
|
||||
]);
|
||||
await h.answer();
|
||||
h.clock.seek(WALL - 7_200_000);
|
||||
await flush();
|
||||
assert.deepEqual(h.calls.at(-1), { type: 'clear' });
|
||||
assert.equal(loads(h.calls).length, 1);
|
||||
h.follower.stop();
|
||||
assert.equal(h.coverageListeners.size, 0);
|
||||
});
|
||||
|
||||
test('back at live the layer is cleared and polls at once', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
h.clock.seek(WALL - 600_000);
|
||||
await flush();
|
||||
const inflight = h.pending[0];
|
||||
h.clock.goLive();
|
||||
await flush();
|
||||
assert.equal(inflight.signal.aborted, true);
|
||||
assert.deepEqual(h.calls.slice(-2), [{ type: 'clear' }, { type: 'live' }]);
|
||||
assert.equal(h.follower.active(), false);
|
||||
h.follower.stop();
|
||||
});
|
||||
|
||||
test('a load the source drops for a newer one is no failure: nothing clears, it is asked again', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
h.clock.seek(WALL - 600_000);
|
||||
await flush();
|
||||
h.pending
|
||||
.shift()
|
||||
.reject(new DOMException('Superseded by a newer request', 'AbortError'));
|
||||
await flush();
|
||||
assert.equal(
|
||||
h.calls.some((call) => call.type === 'clear'),
|
||||
false,
|
||||
'the layer keeps what it shows',
|
||||
);
|
||||
assert.equal(loads(h.calls).length, 1);
|
||||
h.advance(SNAPSHOT_RETRY_MS);
|
||||
await flush();
|
||||
assert.equal(loads(h.calls).length, 2, 'the time is asked for again');
|
||||
assert.equal(loads(h.calls)[1].replace, true);
|
||||
h.follower.stop();
|
||||
});
|
||||
|
||||
test('a layer never has two loads out: the older is aborted before a newer starts', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
const live = () => h.pending.filter(({ signal }) => !signal.aborted).length;
|
||||
h.clock.seek(WALL - 600_000);
|
||||
await flush();
|
||||
await h.answer();
|
||||
h.clock.play();
|
||||
await flush();
|
||||
assert.equal(live(), 1, 'the next step loads');
|
||||
h.follower.reload();
|
||||
await flush();
|
||||
assert.equal(live(), 1, 'a reload drops it first');
|
||||
h.clock.seek(WALL - 900_000);
|
||||
await flush();
|
||||
assert.equal(live(), 1, 'a seek drops it first');
|
||||
assert.equal(h.pending.length, 3);
|
||||
h.follower.stop();
|
||||
assert.equal(live(), 0);
|
||||
});
|
||||
|
||||
test('a source that tells of a better answer has the paused time loaded again, not a playing one', async () => {
|
||||
const h = harness();
|
||||
h.follower.start();
|
||||
const at = WALL - 600_000;
|
||||
h.clock.seek(at);
|
||||
await flush();
|
||||
await h.answer();
|
||||
const before = loads(h.calls).length;
|
||||
for (const listener of h.coverageListeners) listener();
|
||||
await flush();
|
||||
assert.equal(loads(h.calls).length, before + 1);
|
||||
assert.equal(loads(h.calls).at(-1).atMs, at);
|
||||
await h.answer(h.pending.length - 1);
|
||||
h.clock.play();
|
||||
const playing = loads(h.calls).length;
|
||||
for (const listener of h.coverageListeners) listener();
|
||||
await flush();
|
||||
assert.ok(
|
||||
loads(h.calls)
|
||||
.slice(playing)
|
||||
.every((call) => call.atMs !== at),
|
||||
'playing, the time shown is not loaded again',
|
||||
);
|
||||
h.clock.goLive();
|
||||
await flush();
|
||||
const live = loads(h.calls).length;
|
||||
for (const listener of h.coverageListeners) listener();
|
||||
await flush();
|
||||
assert.equal(loads(h.calls).length, live, 'live, nothing is loaded');
|
||||
h.follower.stop();
|
||||
});
|
||||
+14
-4
@@ -8,8 +8,10 @@
|
||||
* clock holds a cursor, fixed or playing. Only the layers that follow the clock
|
||||
* move with the cursor; the rest keep reading the live edge through
|
||||
* `timeFor(id)`. On the page's clock the layers whose data has a time of its
|
||||
* own (the sky, satellites, launches and weather) follow it; live feeds such
|
||||
* as flights and vessels have no time to move to and stay live.
|
||||
* own (the sky, satellites, launches and weather) follow it. Flights,
|
||||
* military aircraft and vessels follow it only when their source declares a
|
||||
* timeline, that is, can answer a snapshot at a given time (see
|
||||
* src/time/snapshotTimeline.js); otherwise they stay live.
|
||||
*
|
||||
* Playback is continuous (`cursor + elapsed × rate`) unless a driver takes
|
||||
* it: weather replay steps its observed frames itself and moves the clock as
|
||||
@@ -64,6 +66,9 @@ export function createWorldClock({
|
||||
let anchorMono = 0;
|
||||
let rate = 1;
|
||||
let playing = false;
|
||||
// How many times the cursor has jumped (a seek, a return to live), so a
|
||||
// layer can tell a jump from the cursor playing on.
|
||||
let jumps = 0;
|
||||
// The driver handle stepping the clock, or null.
|
||||
let active = null;
|
||||
let ticker = null;
|
||||
@@ -118,6 +123,7 @@ export function createWorldClock({
|
||||
buffering: continuous() && buffering(),
|
||||
following: followedIds(),
|
||||
supported: isSupported(),
|
||||
jumps,
|
||||
});
|
||||
// Each listener hears the state as it is when called. A change made from
|
||||
// inside a listener notifies everyone afresh, and the outer round stops, so
|
||||
@@ -182,6 +188,7 @@ export function createWorldClock({
|
||||
live = false;
|
||||
cursorMs = ms;
|
||||
anchorMono = monotonicNow();
|
||||
jumps += 1;
|
||||
changed();
|
||||
},
|
||||
/** Back to the wall clock; playback stops and its rate goes back to 1. */
|
||||
@@ -193,6 +200,7 @@ export function createWorldClock({
|
||||
playing = false;
|
||||
active = null;
|
||||
rate = 1;
|
||||
jumps += 1;
|
||||
// Its driven(false) finds nothing left to stop.
|
||||
stepping?.driver.pause();
|
||||
changed();
|
||||
@@ -300,8 +308,10 @@ export function createWorldClock({
|
||||
active = handle;
|
||||
playing = true;
|
||||
live = false;
|
||||
if (Number.isFinite(atMs)) cursorMs = atMs;
|
||||
else cursorMs ??= now();
|
||||
if (Number.isFinite(atMs)) {
|
||||
cursorMs = atMs;
|
||||
jumps += 1;
|
||||
} else cursorMs ??= now();
|
||||
anchorMono = monotonicNow();
|
||||
if (previous && previous !== handle) previous.driver.pause();
|
||||
changed();
|
||||
|
||||
@@ -387,3 +387,24 @@ test('playing, listeners hear of the time every animation frame', () => {
|
||||
clock.pause();
|
||||
assert.equal(frames.size, 0);
|
||||
});
|
||||
|
||||
test('a seek or a return to live counts as a jump; play does not', () => {
|
||||
const t = fakeTime();
|
||||
const clock = createWorldClock(t.options);
|
||||
assert.equal(clock.getState().jumps, 0);
|
||||
clock.seek(t.wall - 60_000);
|
||||
assert.equal(clock.getState().jumps, 1);
|
||||
clock.seek(t.wall - 60_000);
|
||||
assert.equal(clock.getState().jumps, 1, 'the same paused time is no jump');
|
||||
clock.play();
|
||||
t.advance(1_000);
|
||||
t.timers.forEach(({ fn }) => fn());
|
||||
assert.equal(clock.getState().jumps, 1, 'playing on is no jump');
|
||||
clock.pause();
|
||||
clock.setRate(4);
|
||||
assert.equal(clock.getState().jumps, 1);
|
||||
clock.goLive();
|
||||
assert.equal(clock.getState().jumps, 2);
|
||||
clock.goLive();
|
||||
assert.equal(clock.getState().jumps, 2, 'already live is no jump');
|
||||
});
|
||||
@@ -28,6 +28,14 @@ const MMSI_SCHEMA = Object.freeze({
|
||||
pattern: '^\\d{5,10}$',
|
||||
description: 'Maritime Mobile Service Identity, such as "366999712".',
|
||||
});
|
||||
const VESSEL_ID_SCHEMA = Object.freeze({
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 128,
|
||||
description:
|
||||
'The vessel id a vessel search returned. Vessels can share an MMSI; ' +
|
||||
'the id names one of them.',
|
||||
});
|
||||
const MAX_TRACK_POINTS = 200;
|
||||
const ALL_VESSELS = 1_000_000;
|
||||
|
||||
@@ -47,10 +55,13 @@ const vesselDestination = (value) => {
|
||||
return destination && /\p{L}/u.test(destination) ? destination : null;
|
||||
};
|
||||
|
||||
// `id` is the vessel's key; `mmsi` is its MMSI when known (vessels can share
|
||||
// one), else null.
|
||||
function vesselRow(record, from) {
|
||||
const point = { lat: record.latitude, lon: record.longitude };
|
||||
return {
|
||||
mmsi: record.id,
|
||||
id: record.id,
|
||||
mmsi: text(record.mmsi),
|
||||
name: text(record.name),
|
||||
imo: text(record.imo),
|
||||
type: vesselType(record.type),
|
||||
@@ -194,7 +205,7 @@ export const findVessel = defineTool({
|
||||
const [key] = given;
|
||||
const wanted = args[key].trim().toUpperCase();
|
||||
const matches = {
|
||||
mmsi: (record) => record.id === wanted,
|
||||
mmsi: (record) => String(record.mmsi ?? '') === wanted,
|
||||
imo: (record) => String(record.imo).replace(/^IMO/i, '') === wanted,
|
||||
name: (record) => String(record.name).toUpperCase().includes(wanted),
|
||||
}[key];
|
||||
@@ -227,25 +238,40 @@ export const getVesselTrack = defineTool({
|
||||
name: 'get_vessel_track',
|
||||
title: 'Vessel track',
|
||||
description:
|
||||
'Recent positions of one ship by MMSI, oldest first. Long tracks are ' +
|
||||
`evenly thinned to at most ${MAX_TRACK_POINTS} points.`,
|
||||
'Recent positions of one ship by its vessel id or MMSI, oldest first. ' +
|
||||
`Long tracks are evenly thinned to at most ${MAX_TRACK_POINTS} points.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { mmsi: MMSI_SCHEMA },
|
||||
required: ['mmsi'],
|
||||
properties: { id: VESSEL_ID_SCHEMA, mmsi: MMSI_SCHEMA },
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['vessels'],
|
||||
async run(args, { services, signal }) {
|
||||
const track = await services.vessels.getTrack(args.mmsi, { signal });
|
||||
const given = ['id', 'mmsi'].filter((key) => args[key] != null);
|
||||
if (given.length !== 1)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
'Give exactly one of id or mmsi',
|
||||
);
|
||||
const [key] = given;
|
||||
const wanted = String(args[key]).trim();
|
||||
// A track is asked for by the source's reference for the vessel, which
|
||||
// for an id is the reported vessel's; an MMSI is passed as it is.
|
||||
let reference = wanted;
|
||||
if (key === 'id') {
|
||||
const snapshot = await readVessels(services, signal);
|
||||
const record = snapshot.records.find((item) => item.id === wanted);
|
||||
reference = record?.reference ?? wanted;
|
||||
}
|
||||
const track = await services.vessels.getTrack(reference, { signal });
|
||||
const points = track.records
|
||||
.filter((point) => Number.isFinite(point.observedAtMs))
|
||||
.sort((a, b) => a.observedAtMs - b.observedAtMs);
|
||||
const kept = thinEvenly(points, MAX_TRACK_POINTS);
|
||||
return {
|
||||
summary: points.length
|
||||
? `${countNoun(points.length, 'position')} for ${args.mmsi} from ${isoTime(points[0].observedAtMs)} to ${isoTime(points.at(-1).observedAtMs)}.`
|
||||
: `No recent track is available for ${args.mmsi}.`,
|
||||
? `${countNoun(points.length, 'position')} for ${wanted} from ${isoTime(points[0].observedAtMs)} to ${isoTime(points.at(-1).observedAtMs)}.`
|
||||
: `No recent track is available for ${wanted}.`,
|
||||
data: {
|
||||
view: kept.length
|
||||
? suggestView(services, {
|
||||
@@ -254,7 +280,7 @@ export const getVesselTrack = defineTool({
|
||||
altitudeM: 20_000,
|
||||
})
|
||||
: null,
|
||||
mmsi: args.mmsi,
|
||||
...(key === 'id' ? { id: wanted } : { mmsi: wanted }),
|
||||
total: points.length,
|
||||
returned: kept.length,
|
||||
thinned: kept.length < points.length,
|
||||
|
||||
@@ -5,8 +5,10 @@ import { LiveSourceError } from '../../sources/live/contract.js';
|
||||
import { createVesselSource } from '../../sources/live/standalone.js';
|
||||
import { AISSTREAM_CACHE_MAX } from '../../../server/providers/vessels/ais-store.js';
|
||||
|
||||
// An AIS source's vessel: keyed by its MMSI, which it also reports.
|
||||
const vessel = (id, latitude, longitude, extra = {}) => ({
|
||||
id,
|
||||
mmsi: id,
|
||||
reference: id,
|
||||
latitude,
|
||||
longitude,
|
||||
@@ -79,6 +81,7 @@ test('vessels in an area are nearest first and filterable by type', async () =>
|
||||
['366999712', '538001234'],
|
||||
);
|
||||
assert.deepEqual(all.data.rows[0], {
|
||||
id: '366999712',
|
||||
mmsi: '366999712',
|
||||
name: 'GOLDEN BEAR',
|
||||
imo: '9123456',
|
||||
@@ -164,6 +167,53 @@ test('vessel tracks are ordered and drop untimed positions', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('vessels sharing an MMSI are found as two, each by its own id', async () => {
|
||||
const shared = [
|
||||
vessel('hull-a', 37.81, -122.41, { mmsi: '366999712', name: 'A' }),
|
||||
vessel('hull-b', 37.79, -122.42, {
|
||||
mmsi: '366999712',
|
||||
name: 'B',
|
||||
reference: 'track:hull-b',
|
||||
}),
|
||||
vessel('hull-c', 37.8, -122.4, { mmsi: undefined, name: 'C' }),
|
||||
];
|
||||
const tracks = [];
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
vessels: {
|
||||
getSnapshot: async () => ({ records: shared, freshness: 'current' }),
|
||||
getTrack: async (reference) => {
|
||||
tracks.push(reference);
|
||||
return { records: [] };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const found = await catalog.call('find_vessel', { mmsi: '366999712' });
|
||||
assert.deepEqual(
|
||||
found.data.rows.map((row) => [row.id, row.mmsi]),
|
||||
[
|
||||
['hull-a', '366999712'],
|
||||
['hull-b', '366999712'],
|
||||
],
|
||||
);
|
||||
const area = await catalog.call('vessels_in_area', { area: bay });
|
||||
assert.deepEqual(
|
||||
area.data.rows.find((row) => row.id === 'hull-c').mmsi,
|
||||
null,
|
||||
'a vessel with no MMSI says so',
|
||||
);
|
||||
const track = await catalog.call('get_vessel_track', { id: 'hull-b' });
|
||||
assert.deepEqual(tracks, ['track:hull-b'], "asked by the vessel's reference");
|
||||
assert.equal(track.data.id, 'hull-b');
|
||||
assert.equal(track.summary, 'No recent track is available for hull-b.');
|
||||
await assert.rejects(
|
||||
catalog.call('get_vessel_track', { id: 'hull-b', mmsi: '366999712' }),
|
||||
/exactly one/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a missing AIS key surfaces as an unavailable feed', async () => {
|
||||
const keyless = {
|
||||
getSnapshot: async () => {
|
||||
|
||||
@@ -472,7 +472,12 @@ export class StyleManager extends ShellFacade {
|
||||
initialShareCode,
|
||||
});
|
||||
this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) =>
|
||||
readShareFollowSubject({ viewer: this.viewer, nowMs }),
|
||||
readShareFollowSubject({
|
||||
viewer: this.viewer,
|
||||
nowMs,
|
||||
readSelectedVessel: () =>
|
||||
this.services.aisLiveVesselsLayer?.getSelectedFix?.() ?? null,
|
||||
}),
|
||||
);
|
||||
this.shareLinkManager.setPanelStateProvider(() =>
|
||||
this._buildSharePanelState(),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js';
|
||||
import { VesselRecords } from '../layers/vessels/records.js';
|
||||
|
||||
const RECORD = Object.freeze({
|
||||
id: '211000000',
|
||||
mmsi: '211000000',
|
||||
name: 'NORDIC STAR',
|
||||
type: '70',
|
||||
@@ -65,9 +66,9 @@ function vesselLayer(record) {
|
||||
enabled && selected
|
||||
? { ...vesselCockpitInfo(selected), seaSurfaceHeightM: 0 }
|
||||
: null,
|
||||
setCockpitSubject(mmsi) {
|
||||
calls.push(['subject', mmsi]);
|
||||
subject = mmsi;
|
||||
setCockpitSubject(id) {
|
||||
calls.push(['subject', id]);
|
||||
subject = id;
|
||||
return true;
|
||||
},
|
||||
// Like the layer, leaves out the ship whose bridge the Cockpit rides.
|
||||
@@ -75,7 +76,7 @@ function vesselLayer(record) {
|
||||
radarQueries.push({ lat, lon, rangeM, maxCount });
|
||||
const own = {
|
||||
...NEIGHBOUR,
|
||||
id: record.mmsi,
|
||||
id: record.id,
|
||||
latitude: record.lat,
|
||||
longitude: record.lon,
|
||||
};
|
||||
@@ -257,7 +258,7 @@ test('entering the bridge hides the vessel and shows bridge readouts', async (t)
|
||||
await settle();
|
||||
assert.equal(cockpit.active, true);
|
||||
assert.equal(cockpit.subjectKind, 'vessel');
|
||||
assert.deepEqual(vessels.calls, [['subject', RECORD.mmsi]]);
|
||||
assert.deepEqual(vessels.calls, [['subject', RECORD.id]]);
|
||||
assert.equal(document.body.classList.contains('cockpit-mode'), true);
|
||||
|
||||
assert.equal(
|
||||
@@ -303,7 +304,7 @@ test('exiting the bridge releases the vessel and returns to its map view', async
|
||||
assert.equal(cockpit.active, false);
|
||||
assert.equal(cockpit.subjectKind, null);
|
||||
assert.deepEqual(vessels.calls, [
|
||||
['subject', RECORD.mmsi],
|
||||
['subject', RECORD.id],
|
||||
['subject', null],
|
||||
['focus'],
|
||||
]);
|
||||
@@ -374,7 +375,7 @@ test('tracking an aircraft from the bridge ends it without refocusing the vessel
|
||||
assert.equal(viewer.trackedEntity, aircraft, 'the aircraft keeps tracking');
|
||||
assert.equal(aircraft.show, true, 'the aircraft was never adopted');
|
||||
assert.deepEqual(vessels.calls, [
|
||||
['subject', RECORD.mmsi],
|
||||
['subject', RECORD.id],
|
||||
['subject', null],
|
||||
]);
|
||||
});
|
||||
@@ -1474,7 +1475,7 @@ test('a fix the feed keeps re-serving goes on ageing into a stale feed', async (
|
||||
effects,
|
||||
);
|
||||
poll();
|
||||
const record = records.byMmsi.get(RECORD.mmsi);
|
||||
const record = records.byId.get(RECORD.id);
|
||||
const { $, cockpit, shots } = fixture(t, { record });
|
||||
cockpit.enter();
|
||||
await settle();
|
||||
@@ -1540,7 +1541,12 @@ test("a ship's double-click memory does not carry over to another ship", async (
|
||||
heightSensitiveProjection(viewer);
|
||||
cockpit.onScenePointerDown(1000);
|
||||
// Within the memory another ship is selected, drawn elsewhere.
|
||||
vessels.select({ ...RECORD, mmsi: '211000009', name: 'OTHER' });
|
||||
vessels.select({
|
||||
...RECORD,
|
||||
id: '211000009',
|
||||
mmsi: '211000009',
|
||||
name: 'OTHER',
|
||||
});
|
||||
heightSensitiveProjection(viewer, { x: 900 });
|
||||
assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }, 1250), false);
|
||||
assert.equal(cockpit.active, false);
|
||||
|
||||
@@ -164,7 +164,7 @@ export function maybeRefreshRegionalBrief(info) {
|
||||
!Number.isFinite(info.longitude)
|
||||
)
|
||||
return;
|
||||
const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.mmsi || info.noradId || info.registration || info.callsign || 'unknown'}`;
|
||||
const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || (info.kind === 'vessel' ? info.id : '') || info.noradId || info.registration || info.callsign || 'unknown'}`;
|
||||
if (subjectId !== this.regionalBriefSubjectId) {
|
||||
this.regionalBriefAbort?.abort();
|
||||
this.regionalBriefAbort = null;
|
||||
|
||||
@@ -128,7 +128,7 @@ function vesselSurfaceM(info, nowMs, at = info) {
|
||||
: 0;
|
||||
const scene = this.viewer.scene;
|
||||
let read =
|
||||
this.vesselWaterline?.mmsi === info.mmsi ? this.vesselWaterline : null;
|
||||
this.vesselWaterline?.vesselId === info.id ? this.vesselWaterline : null;
|
||||
if (
|
||||
typeof scene?.sampleHeightMostDetailed === 'function' &&
|
||||
(!read || nowMs - read.sampledMs >= VESSEL_WATERLINE_REFRESH_MS)
|
||||
@@ -138,7 +138,7 @@ function vesselSurfaceM(info, nowMs, at = info) {
|
||||
);
|
||||
if (!read) {
|
||||
read = {
|
||||
mmsi: info.mmsi,
|
||||
vesselId: info.id,
|
||||
heightM: loadedWaterlineM(scene, probes, seaM),
|
||||
sampledMs: nowMs,
|
||||
// Whether a full-detail read has answered for this ship.
|
||||
@@ -219,7 +219,7 @@ function updateVessel(nowMs) {
|
||||
return;
|
||||
}
|
||||
const info = this.readVesselInfo();
|
||||
if (!info || info.mmsi !== this.subjectId) {
|
||||
if (!info || info.id !== this.subjectId) {
|
||||
this.exit({ restoreTracking: false });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export class CockpitViewController {
|
||||
*/
|
||||
this.subjectKind = null;
|
||||
/**
|
||||
* The vessel subject's MMSI or the satellite's NORAD id; aircraft are
|
||||
* The vessel subject's id or the satellite's NORAD id; aircraft are
|
||||
* identified by trackedEntity.
|
||||
*/
|
||||
this.subjectId = null;
|
||||
|
||||
@@ -255,7 +255,7 @@ export class CockpitCoordinator {
|
||||
: vessel
|
||||
? {
|
||||
kind: 'vessel',
|
||||
id: vessel.mmsi,
|
||||
id: vessel.id,
|
||||
layerId: vessel.layerId,
|
||||
callsign: null,
|
||||
name: vessel.name,
|
||||
|
||||
@@ -83,8 +83,10 @@ export function onScenePointerDown(nowMs = performance.now()) {
|
||||
return;
|
||||
}
|
||||
const at = subjectCanvasPosition.call(this);
|
||||
const mmsi = this.readVesselInfo()?.mmsi ?? null;
|
||||
this.doubleClickStart = at ? { x: at.x, y: at.y, atMs: nowMs, mmsi } : null;
|
||||
const vesselId = this.readVesselInfo()?.id ?? null;
|
||||
this.doubleClickStart = at
|
||||
? { x: at.x, y: at.y, atMs: nowMs, vesselId }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,7 +113,7 @@ export function onSceneDoubleClick(position, nowMs = performance.now()) {
|
||||
// The memory is the ship's it was taken for: one followed since (an
|
||||
// aircraft, another ship) is hit only where it is drawn.
|
||||
this.resolveEntryKind() === 'vessel' &&
|
||||
start.mmsi === (this.readVesselInfo()?.mmsi ?? null) &&
|
||||
start.vesselId === (this.readVesselInfo()?.id ?? null) &&
|
||||
doubleClickHitsSubject(position, start));
|
||||
if (!hit) return false;
|
||||
this.enter();
|
||||
|
||||
@@ -65,7 +65,12 @@ export function resolveEntryKind() {
|
||||
|
||||
export function dispatchCockpitModeChanged(active, info = null) {
|
||||
const subjectId = active
|
||||
? String(info?.icao24 || info?.mmsi || info?.noradId || '')
|
||||
? String(
|
||||
info?.icao24 ||
|
||||
(info?.kind === 'vessel' ? info.id : '') ||
|
||||
info?.noradId ||
|
||||
'',
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase() || null
|
||||
: null;
|
||||
@@ -245,7 +250,7 @@ export function enter({ kind = null } = {}) {
|
||||
// Each session reads its own water: never a height left from the last.
|
||||
this.vesselWaterline = null;
|
||||
this.vesselFix = null;
|
||||
this.subjectId = vessel ? info.mmsi : satellite ? info.noradId : null;
|
||||
this.subjectId = vessel ? info.id : satellite ? info.noradId : null;
|
||||
this.trackedEntity = entity;
|
||||
if (entity) {
|
||||
this.trackedEntityWasShown = entity.show;
|
||||
@@ -255,7 +260,7 @@ export function enter({ kind = null } = {}) {
|
||||
this.services.satellitesLayer?.setCockpitSubject?.(info.noradId);
|
||||
this.beginOrbital();
|
||||
} else {
|
||||
this.services.aisLiveVesselsLayer?.setCockpitSubject?.(info.mmsi);
|
||||
this.services.aisLiveVesselsLayer?.setCockpitSubject?.(info.id);
|
||||
}
|
||||
// A ship reporting neither heading nor course keeps the camera's own
|
||||
// heading: no direction is made up for it.
|
||||
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user