diff --git a/changelog.d/0-contacts-follow-world-clock.md b/changelog.d/0-contacts-follow-world-clock.md index 6f9a1d402..3755ed7d6 100644 --- a/changelog.d/0-contacts-follow-world-clock.md +++ b/changelog.d/0-contacts-follow-world-clock.md @@ -3,5 +3,34 @@ (`source.timeline`, read by `readSnapshotTimeline` from `gods-eye-view/sources/live`). Away from live they show the snapshot at the clock's time and move on with it as it plays; back at live they poll as - before. A source with no timeline keeps them live, unchanged (Sameh Khamis, + before. A source's `timeline.subscribe` may also tell of a better answer + for a time it has answered; paused, the layer loads the time shown again. + A source with no timeline keeps them live, unchanged (Sameh Khamis, @samehkhamis, #PR). +- AIS Vessels are keyed by the stable id their live source gives (`id` on a + vessel record), not by MMSI: vessels can share an MMSI, through spoofing, + reused numbers or defaults such as 0, and a number can move to another + hull. Two vessels sharing an MMSI are now two vessels, each selected, + followed and carded on its own. The MMSI is an attribute (`mmsi`), shown + when known and `--` otherwise, up to ten digits; looking a vessel up or + selecting it by MMSI (an older link, say) still finds a vessel with it, and + a vessel with no name is no longer named by its id. `vesselSnapshot` takes + `idFor(row)` for a source whose rows carry an id of their own. Tool and voice results carry the vessel's id beside its MMSI + (`id`, or `vesselId` where `id` is a display label), and `get_vessel_track` + also takes an `id`. AISStream has only MMSIs, so its vessels are keyed by + MMSI and nothing changes there (Sameh Khamis, @samehkhamis, #PR). +- A share link made while a vessel is selected (and the camera follows + nothing) now opens that vessel: the link names it, opens with the camera + where it was, looking at the vessel, and selects the vessel once the feed + has it, at the link's world time when it has one. A vessel id that is a + source's stable id, such as a UUID, travels as its 16 bytes in a new thing + field (13), so the link stays short (62 characters where a followed + aircraft's is 44). MMSI and other ids, and links already shared, are + unchanged; an app that predates the field opens such a link at the same + camera, without selecting the vessel. A camera that no follow offset can + carry (below the vessel's horizon, say) still makes a place view. + A shared flight, military flight, vessel or satellite that is not reporting + now says so in a sentence ("The shared vessel isn't reporting now — showing + where it was at 14:05"), with its id on a second line under it (ICAO, + NORAD, MMSI or vessel UUID) instead of at the head of the sentence (Sameh + Khamis, @samehkhamis, #PR). diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 591db02dc..b30c3aba1 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -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. diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index bf69653f9..8d2e99383 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -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` diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 80fd80b13..7fd4c0937 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -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 | diff --git a/src/data/aisLiveVessels.analyst.test.mjs b/src/data/aisLiveVessels.analyst.test.mjs index 636f84e31..5a015be50 100644 --- a/src/data/aisLiveVessels.analyst.test.mjs +++ b/src/data/aisLiveVessels.analyst.test.mjs @@ -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); diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 193136264..e427a5412 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -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); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index 0251738f3..384c72d6d 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -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 }], ]; diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 430ca3362..71d22b49b 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -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( diff --git a/src/data/militaryAwarenessEngine.js b/src/data/militaryAwarenessEngine.js index 136e9d2fa..459cda6db 100644 --- a/src/data/militaryAwarenessEngine.js +++ b/src/data/militaryAwarenessEngine.js @@ -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; diff --git a/src/data/pickRegistry.js b/src/data/pickRegistry.js index ef3d8074e..a01e80d36 100644 --- a/src/data/pickRegistry.js +++ b/src/data/pickRegistry.js @@ -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; } diff --git a/src/data/pickRegistry.test.mjs b/src/data/pickRegistry.test.mjs index 43f11260f..3fec66d00 100644 --- a/src/data/pickRegistry.test.mjs +++ b/src/data/pickRegistry.test.mjs @@ -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' }], diff --git a/src/data/pickedContext.js b/src/data/pickedContext.js index 28c303564..e54ab69c5 100644 --- a/src/data/pickedContext.js +++ b/src/data/pickedContext.js @@ -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, diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js index fbf209040..0a8a164f7 100644 --- a/src/data/vesselLabels.js +++ b/src/data/vesselLabels.js @@ -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, }; } diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index d2f662b5d..1f3692a60 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -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}" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 2648668c4..88ab65f22 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -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}" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 4fc5e30b6..a16074c79 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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}" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 536203664..49e9b6387 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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}" } diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index 5a6fe9404..b1c523a7a 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -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}" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 329a1fd87..528964948 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -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}" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d7164c2e6..347156b7a 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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}" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index e4b376011..233a94f20 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -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}" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 78622d3e3..3e5a78ef8 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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}" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 08d6417df..317c7da03 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -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}" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 503a6716e..54a106fe1 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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}" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c2a279684..93170fa7f 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -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}" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 0e5988d11..c2e5080e0 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -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}" ] } diff --git a/src/layers/awareness/history.js b/src/layers/awareness/history.js index e8e05505a..b7aa50e22 100644 --- a/src/layers/awareness/history.js +++ b/src/layers/awareness/history.js @@ -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 ); } diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index 182baa6e1..bd5078499 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -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 { diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 050d144c2..e8bb26a8c 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -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), diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index bcf3459f1..49c0264e2 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -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'); } diff --git a/src/layers/vessels/clockFollow.test.mjs b/src/layers/vessels/clockFollow.test.mjs index 4f0978c07..84e652bf0 100644 --- a/src/layers/vessels/clockFollow.test.mjs +++ b/src/layers/vessels/clockFollow.test.mjs @@ -37,19 +37,19 @@ function store() { test('rows at another time stand in for every vessel, selected or not', () => { const probe = store(); probe.reconcile([row('111'), row('222')]); - const selectedRecord = probe.records.byMmsi.get('222'); + const selectedRecord = probe.records.byId.get('222'); probe.reconcile([row('111', { last_position_epoch: 900 })], { complete: false, selectedRecord, replace: true, }); - assert.deepEqual([...probe.records.byMmsi.keys()], ['111']); + assert.deepEqual([...probe.records.byId.keys()], ['111']); assert.deepEqual(probe.events.slice(-3), [ ['update', '111'], ['remove', '222', true], ['removed', '222'], ]); - assert.equal(probe.records.byMmsi.get('111').motion, null); + assert.equal(probe.records.byId.get('111').motion, null); }); test('following the clock, a vessel keeps where its fix moved from', () => { @@ -58,7 +58,7 @@ test('following the clock, a vessel keeps where its fix moved from', () => { probe.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })], { follow: true, }); - const record = probe.records.byMmsi.get('111'); + const record = probe.records.byId.get('111'); assert.deepEqual(record.motion, { fromLat: 51.93, fromLon: 4.05, @@ -75,7 +75,7 @@ test('following the clock, a vessel keeps where its fix moved from', () => { live.reconcile([row('111')]); live.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })]); assert.equal( - Object.hasOwn(live.records.byMmsi.get('111'), 'motion'), + Object.hasOwn(live.records.byId.get('111'), 'motion'), false, 'live records are as before', ); diff --git a/src/layers/vessels/cockpitSubject.js b/src/layers/vessels/cockpitSubject.js index d92689a64..52f4412a3 100644 --- a/src/layers/vessels/cockpitSubject.js +++ b/src/layers/vessels/cockpitSubject.js @@ -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(), diff --git a/src/layers/vessels/cockpitSubject.test.mjs b/src/layers/vessels/cockpitSubject.test.mjs index 572ff199d..784749b37 100644 --- a/src/layers/vessels/cockpitSubject.test.mjs +++ b/src/layers/vessels/cockpitSubject.test.mjs @@ -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', + ); +}); diff --git a/src/layers/vessels/evidence.js b/src/layers/vessels/evidence.js index c5e593e26..3a8c1b9ce 100644 --- a/src/layers/vessels/evidence.js +++ b/src/layers/vessels/evidence.js @@ -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, diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js index 6f01339c9..735f32daa 100644 --- a/src/layers/vessels/ingestion.js +++ b/src/layers/vessels/ingestion.js @@ -201,9 +201,11 @@ export function createIngestion({ return { reconciled: true, ...snapshot }; } + /** The layer's row for a source record: keyed by its id, MMSI shown when known. */ function vesselDisplayRow(record) { return { - mmsi: record.id, + id: record.id, + mmsi: String(record.mmsi ?? '').trim(), reference: record.reference, lat: record.latitude, lon: record.longitude, diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index e89b0ebd9..4c457c198 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -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. diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 2404434a0..92c4ec493 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -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'], + ); +}); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index c0ce32355..d927def6d 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -28,6 +28,9 @@ import { } from './policy.js'; import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; +const UUID_KEY = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + export function createQueries({ vesselState, services, @@ -38,6 +41,28 @@ export function createQueries({ const { state } = vesselState; const { formatKnots } = services.labels; + /** + * The vessel keyed `id`, else the first whose MMSI is `id`: a link or a + * caller that names a vessel by its MMSI still finds it. A share link + * carries a UUID key in lowercase, so a UUID matches in any case. + * @param {string} id + * @returns {object|null} + */ + function recordFor(id) { + const record = state.records.byId?.get(id); + if (record) return record; + if (UUID_KEY.test(id)) { + const wanted = id.toLowerCase(); + for (const candidate of state.records.byId?.values() ?? []) + if (candidate.id.toLowerCase() === wanted) return candidate; + return null; + } + if (!/^\d{1,10}$/.test(id)) return null; + for (const candidate of state.records.byId?.values() ?? []) + if (candidate.mmsi === id) return candidate; + return null; + } + /** * Seconds until the server's next reconnect attempt, or 0 when none is * scheduled. Mirrors the flights layer's `retryInSec` chip affordance. @@ -175,8 +200,9 @@ export function createQueries({ * fields are null, never NaN/undefined. navStatus is always null: the * /api/vessels proxy does not surface AIS NavigationalStatus, so it * cannot be derived client-side. - * @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry. - * @returns {{id: string|null, mmsi: string|null, name: string|null, + * @param {Object|null|undefined} record - `state.records.byId`/`state.records.all` entry. + * @returns {{id: string|null, vesselId: string|null, mmsi: string|null, + * name: string|null, * lat: number|null, lon: number|null, speedKts: number|null, * courseDeg: number|null, shipType: string|null, destination: string|null, * navStatus: null}} @@ -188,10 +214,14 @@ export function createQueries({ const t = String(v ?? '').trim(); return t || null; }; + const vesselId = text(record?.id); const mmsi = text(record?.mmsi); const name = text(record?.name); return { - id: name || mmsi, + // A display label, as every layer's analyst `id` is; `vesselId` is the + // key and `mmsi` the vessel's MMSI when known. + id: name || mmsi || vesselId, + vesselId, mmsi, name, lat: num(record?.lat), @@ -225,32 +255,32 @@ export function createQueries({ * trail picks as no-ops. The interaction wire also reserves sibling-owned * picks before this reducer so their camera action cannot mutate AIS state. * - * @param {{selectedMmsi?: string|number|null, pickedMmsi?: string|number|null, + * @param {{selectedId?: string|number|null, pickedId?: string|number|null, * gesture?: 'click'|'escape'}} input - Current selection plus owned pick. * @returns {{action: 'none'|'select'|'deselect'}} */ function reduceVesselSelection(input = {}) { - const selectedMmsi = normalizeSelectionMmsi(input.selectedMmsi); - const pickedMmsi = normalizeSelectionMmsi(input.pickedMmsi); + const selectedId = normalizeSelectionId(input.selectedId); + const pickedId = normalizeSelectionId(input.pickedId); const gesture = input.gesture || 'click'; if (gesture === 'escape') { - return selectedMmsi ? { action: 'deselect' } : { action: 'none' }; + return selectedId ? { action: 'deselect' } : { action: 'none' }; } if (gesture !== 'click') { return { action: 'none' }; } - if (pickedMmsi) { - if (pickedMmsi === selectedMmsi) { + if (pickedId) { + if (pickedId === selectedId) { return { action: 'none' }; } return { action: 'select' }; } - return selectedMmsi ? { action: 'deselect' } : { action: 'none' }; + return selectedId ? { action: 'deselect' } : { action: 'none' }; } - function normalizeSelectionMmsi(value) { + function normalizeSelectionId(value) { if (value === null || value === undefined) return null; const text = String(value).trim(); return text || null; @@ -275,9 +305,10 @@ export function createQueries({ statsRefreshInterval: 1000, /** - * Find a vessel by exact MMSI or case-insensitive name substring. - * @param {string|number} query MMSI or partial vessel name. - * @returns {{ mmsi: string, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null} + * Find a vessel by its exact id, exact MMSI or case-insensitive name + * substring. Vessels can share an MMSI; the first that has it is found. + * @param {string|number} query Vessel id, MMSI or partial vessel name. + * @returns {{ id: string, mmsi: string, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null} */ findByQuery(query) { if (query === null || query === undefined) return null; @@ -286,9 +317,9 @@ export function createQueries({ const q = String(query).trim(); if (!q) return null; - let record = null; - if (/^\d+$/.test(q)) { - record = state.records.byMmsi.get(q) || null; + let record = state.records.byId.get(q) || null; + if (!record && /^\d+$/.test(q)) { + record = records.find((r) => r.mmsi === q) || null; } if (!record) { const lower = q.toLowerCase(); @@ -306,6 +337,7 @@ export function createQueries({ components.rendering.getVisual(record).position; if (!position) return null; return { + id: record.id, mmsi: record.mmsi, name: record.name, position, @@ -322,7 +354,7 @@ export function createQueries({ * @param {Cesium.Cartesian3} centerCartesian Center of the search. * @param {number} rangeM Max distance in meters (non-finite = unbounded). * @param {number} [maxCount=25] Maximum entries to return. - * @returns {Array<{ mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>} + * @returns {Array<{ id: string, mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>} */ getNearby(centerCartesian, rangeM, maxCount = 25) { const records = state.records.all; @@ -342,6 +374,7 @@ export function createQueries({ const distanceM = Cesium.Cartesian3.distance(centerCartesian, position); if (!Number.isFinite(distanceM) || distanceM > range) continue; entries.push({ + id: record.id, mmsi: record.mmsi, name: record.name, position, @@ -363,7 +396,7 @@ export function createQueries({ * @returns {Array<{ id: string, name: string, latitude: number, * longitude: number, course: number|null, heading: number|null, * type: string, color: string, stale: boolean }>} Empty while the - * layer is off. A ship with no MMSI has an `unkeyed:` id. + * layer is off. A ship with no id has an `unkeyed:` id. */ getRadarContacts(lat, lon, rangeM, maxCount = 60) { const records = state.records.all; @@ -373,7 +406,7 @@ export function createQueries({ const cap = Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 60; const center = { lat, lon }; - const subject = state.cockpitSubjectMmsi; + const subject = state.cockpitSubjectId; const finite = (value) => { if (value === null || value === undefined || value === '') return null; const number = Number(value); @@ -382,7 +415,7 @@ export function createQueries({ const entries = []; records.forEach((record, index) => { - if (subject && String(record.mmsi) === subject) return; + if (subject && String(record.id) === subject) return; const distanceM = greatCircleMeters(center, { lat: record.lat, lon: record.lon, @@ -392,8 +425,8 @@ export function createQueries({ }); entries.sort((a, b) => a.distanceM - b.distanceM); return entries.slice(0, cap).map(({ record, index }) => ({ - // A ship with no MMSI still needs a key of its own on the scope. - id: record.mmsi ? String(record.mmsi) : `unkeyed:${index}`, + // A ship with no id still needs a key of its own on the scope. + id: record.id ? String(record.id) : `unkeyed:${index}`, name: String(record.name || record.mmsi || ''), latitude: record.lat, longitude: record.lon, @@ -414,22 +447,22 @@ export function createQueries({ * Whether this layer still carries a vessel, in O(1). * * Mirror of `flights.hasContact`: presence consumers must not infer absence - * from the capped `getAllPositions` rows. `vesselMap` is MMSI-keyed. + * from the capped `getAllPositions` rows. Records are keyed by id. * A disabled layer keeps its records, so it must decline rather than answer * from data the user can no longer see. - * @param {string} mmsi Vessel identifier. + * @param {string} id Vessel id. * @returns {boolean|null} Presence, or null when the layer is disabled or * holds no data and therefore cannot answer. */ - hasContact(mmsi) { + hasContact(id) { if ( !state.feed.enabled || - !state.records.byMmsi || - state.records.byMmsi.size === 0 + !state.records.byId || + state.records.byId.size === 0 ) return null; - if (!mmsi) return false; - return state.records.byMmsi.has(String(mmsi).trim()); + if (!id) return false; + return Boolean(recordFor(String(id).trim())); }, getAllPositions(maxCount = 800) { @@ -445,8 +478,8 @@ export function createQueries({ const position = visual.billboard?.position || visual.position; if (!position) continue; result.push({ - id: record.mmsi, - label: record.name || record.mmsi, + id: record.id, + label: record.name || record.mmsi || record.id, position, latitude: record.lat, longitude: record.lon, @@ -479,16 +512,16 @@ export function createQueries({ }, /** - * Select a vessel by MMSI via the same path as a map click - * (highlight + HUD update). - * @param {string|number} mmsi Vessel MMSI. + * Select a vessel by id via the same path as a map click + * (highlight + HUD update). An AIS feed's vessels are keyed by MMSI. + * @param {string|number} id Vessel id. * @returns {boolean} True if a matching vessel was selected. */ - selectById(mmsi) { - if (mmsi === null || mmsi === undefined) return false; - const target = String(mmsi).trim(); + selectById(id) { + if (id === null || id === undefined) return false; + const target = String(id).trim(); if (!target) return false; - const record = state.records.byMmsi.get(target); + const record = recordFor(target); if (!record) return false; components.selection.selectVessel(record); return true; @@ -505,12 +538,13 @@ export function createQueries({ /** * Get info about the currently selected vessel. - * @returns {{ mmsi: string, name: string, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null} + * @returns {{ id: string, mmsi: string, name: string, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null} */ getSelectedInfo() { const record = state.selectedRecord; if (!record) return null; return { + id: record.id, mmsi: record.mmsi, name: record.name, latitude: record.lat, @@ -521,6 +555,33 @@ export function createQueries({ }; }, + /** + * Where the selected vessel is drawn now, for a share link that names it: + * its id, its drawn position and the time that position stands for (the + * world time while the layer follows the clock, else its fix's time). + * @returns {{ id: string, position: Cesium.Cartesian3, observedAtMs: number|null }|null} + * Null when the layer is off, nothing is selected or it is not drawn. + */ + getSelectedFix() { + if (!state.feed.enabled) return null; + const record = state.selectedRecord; + if (!record?.id) return null; + const visual = components.rendering.getVisual(record); + const position = visual?.billboard?.position || visual?.position; + if (!position) return null; + const fixEpoch = record.lastPositionEpoch; + const observedAtMs = followsClock(vesselState) + ? vesselState.worldNow() + : Number.isFinite(fixEpoch) + ? fixEpoch * 1000 + : (record.fixReceivedAtMs ?? null); + return { + id: record.id, + position, + observedAtMs: Number.isFinite(observedAtMs) ? observedAtMs : null, + }; + }, + /** * Describe the selected vessel for the Cockpit bridge view. * @returns {object|null} `vesselCockpitInfo` fields plus `seaSurfaceHeightM` @@ -546,16 +607,16 @@ export function createQueries({ /** * Mark the vessel whose bridge the Cockpit camera occupies, or release it. * Its sprite, card and detection bracket stay hidden while it is held. - * @param {string|number|null} mmsi Subject MMSI, or null to release. + * @param {string|number|null} id Subject vessel id, or null to release. * @returns {boolean} Always true. */ - setCockpitSubject(mmsi) { - const next = normalizeSelectionMmsi(mmsi); - if (state.cockpitSubjectMmsi === next) return true; - state.cockpitSubjectMmsi = next; + setCockpitSubject(id) { + const next = normalizeSelectionId(id); + if (state.cockpitSubjectId === next) return true; + state.cockpitSubjectId = next; // The ridden ship's trail runs from under the eye: hidden, its history // kept, while the bridge holds the ship it belongs to. - state.trail?.setVisible?.(!next || next !== state.trailMmsi); + state.trail?.setVisible?.(!next || next !== state.trailId); components.rendering.updateVisibility(true); return true; }, @@ -608,7 +669,7 @@ export function createQueries({ if (!position) continue; result.push({ position, - sourceId: record.mmsi, + sourceId: record.id, id: record.name || record.mmsi || 'VESSEL', type: 'SEA', skipLabel: record === selected, @@ -676,7 +737,7 @@ export function createQueries({ mapAnalystRecord, vesselDatumHeightM, reduceVesselSelection, - normalizeSelectionMmsi, + normalizeSelectionId, methods, }; } diff --git a/src/layers/vessels/radarContacts.test.mjs b/src/layers/vessels/radarContacts.test.mjs index d89d7cf5d..001f266e3 100644 --- a/src/layers/vessels/radarContacts.test.mjs +++ b/src/layers/vessels/radarContacts.test.mjs @@ -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]); diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js index c0fc2d49e..17296cc62 100644 --- a/src/layers/vessels/records.js +++ b/src/layers/vessels/records.js @@ -3,17 +3,25 @@ import { SELECTED_PIN_REFRESHES, } from './recordPolicy.js'; -/** Normalize AIS display fields without allocating scene resources. */ +/** + * Normalize AIS display fields without allocating scene resources. `id` is the + * vessel's key, the stable id its source gives; a row that names none (an + * AIS feed's own rows) is keyed by its MMSI. `mmsi` is only an attribute, + * shown when known: vessels can share one. A row with neither is unkeyed. + */ export function normalizeVessel(row) { const lat = Number(row.lat); const lon = Number(row.lon); if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + const mmsi = String(row.mmsi || '').trim(); + const id = String(row.id ?? mmsi).trim(); return { lat, lon, - name: String(row.name || row.mmsi || 'VESSEL'), - mmsi: String(row.mmsi || '').trim(), - reference: row.reference ?? String(row.mmsi || '').trim(), + name: String(row.name || mmsi || 'VESSEL'), + id, + mmsi, + reference: row.reference ?? id, imo: String(row.imo || ''), type: String(row.type || ''), destination: String(row.destination || ''), @@ -68,7 +76,7 @@ function motionBetween(record, next) { export class VesselRecords { constructor({ now = () => Date.now() } = {}) { this.now = now; - this.byMmsi = new Map(); + this.byId = new Map(); this.unkeyed = []; this.all = []; } @@ -112,14 +120,14 @@ export class VesselRecords { if (!next) continue; next.receivedAtMs = receivedAtMs; stampFix(next, receivedAtMs, feedLatestEpoch); - if (!next.mmsi) { + if (!next.id) { effects.add(next); this.unkeyed.push(next); continue; } - if (seen.has(next.mmsi)) continue; - seen.add(next.mmsi); - const record = this.byMmsi.get(next.mmsi); + if (seen.has(next.id)) continue; + seen.add(next.id); + const record = this.byId.get(next.id); if (record) { const before = effects.beforeUpdate(record); // A feed re-serving a cached row repeats its fix: the fix keeps the @@ -131,6 +139,7 @@ export class VesselRecords { if (replace) record.motion = null; else if (follow) record.motion = motionBetween(record, next); record.reference = next.reference; + record.mmsi = next.mmsi; record.receivedAtMs = next.receivedAtMs; record.lat = next.lat; record.lon = next.lon; @@ -148,15 +157,15 @@ export class VesselRecords { effects.updated(record, before); } else { effects.add(next); - this.byMmsi.set(next.mmsi, next); + this.byId.set(next.id, next); } } - for (const [mmsi, record] of this.byMmsi) { - if (seen.has(mmsi)) continue; + for (const [id, record] of this.byId) { + if (seen.has(id)) continue; if (replace) { effects.remove(record, record === selectedRecord); - this.byMmsi.delete(mmsi); - effects.removed(mmsi); + this.byId.delete(id); + effects.removed(id); continue; } if ( @@ -179,18 +188,18 @@ export class VesselRecords { } // Selection teardown still sees the record before its store entry is removed. effects.remove(record, record === selectedRecord); - this.byMmsi.delete(mmsi); - effects.removed(mmsi); + this.byId.delete(id); + effects.removed(id); } - if (this.byMmsi.size + this.unkeyed.length > cap) { - for (const [mmsi, record] of this.byMmsi) { - if (this.byMmsi.size + this.unkeyed.length <= cap) break; - if (seen.has(mmsi) || record === selectedRecord) continue; + if (this.byId.size + this.unkeyed.length > cap) { + for (const [id, record] of this.byId) { + if (this.byId.size + this.unkeyed.length <= cap) break; + if (seen.has(id) || record === selectedRecord) continue; effects.remove(record, false); - this.byMmsi.delete(mmsi); - effects.removed(mmsi); + this.byId.delete(id); + effects.removed(id); } } - this.all = [...this.byMmsi.values(), ...this.unkeyed]; + this.all = [...this.byId.values(), ...this.unkeyed]; } } diff --git a/src/layers/vessels/records.test.mjs b/src/layers/vessels/records.test.mjs index 9efa91130..d531ff532 100644 --- a/src/layers/vessels/records.test.mjs +++ b/src/layers/vessels/records.test.mjs @@ -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); +}); diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index ed6ea4ed7..7a6aa6739 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -307,7 +307,7 @@ export function createRendering({ if (visual.billboard) { // The bridge camera sits on this sprite; showing it would fill the view. visual.billboard.show = - visible && record.mmsi !== state.cockpitSubjectMmsi; + visible && record.id !== state.cockpitSubjectId; if (visible && doRotations && scene) { const rot = screenProjectedRotation( scene, @@ -445,8 +445,7 @@ export function createRendering({ const selected = state.selectedRecord; // From the bridge, the selected vessel is the viewer; its card would be // pinned to the camera position. - const selectedCarded = - selected && selected.mmsi !== state.cockpitSubjectMmsi; + const selectedCarded = selected && selected.id !== state.cockpitSubjectId; const entries = selectedCarded ? [components.cards.buildSelectedVesselCard(selected)] : []; @@ -532,17 +531,17 @@ export function createRendering({ VESSEL_CARD_FADE_DISTANCE_M, ); if (!card.interactive) return card; - const mmsi = String(card.id || '').startsWith('vessel:') + const id = String(card.id || '').startsWith('vessel:') ? card.id.slice('vessel:'.length) : ''; + const mmsi = state.records.byId.get(id)?.mmsi || ''; return { ...card, - accessibilityLabel: t('Focus vessel {name}, MMSI {mmsi}', { - name: card.title, - mmsi, - }), + accessibilityLabel: mmsi + ? t('Focus vessel {name}, MMSI {mmsi}', { name: card.title, mmsi }) + : t('Focus {label}', { label: card.title }), activate: () => { - const record = state.records.byMmsi.get(mmsi); + const record = state.records.byId.get(id); if (!record) return false; components.selection.selectAndFocusVessel(record); return true; @@ -574,7 +573,8 @@ export function createRendering({ text && text !== 'VESSEL' && !/^MMSI\s*\d+$/i.test(text) && - text !== record.mmsi, + text !== record.mmsi && + text !== record.id, ); } diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 4b801b9c9..99f3b674a 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -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, diff --git a/src/layers/vessels/snapshotRenderer.js b/src/layers/vessels/snapshotRenderer.js index a461c1c17..584b17417 100644 --- a/src/layers/vessels/snapshotRenderer.js +++ b/src/layers/vessels/snapshotRenderer.js @@ -44,7 +44,7 @@ export function createVesselSnapshotRenderer({ const nextIcon = rendering.shipIcon(record, selected); if (nextIcon !== prevIcon) visual.billboard.image = nextIcon; } - if (record.mmsi === state.trailMmsi) + if (record.id === state.trailId) tracking.appendSelectedVesselTrailFix(record); if (selected) { cards.updateSelectedVesselHud(record); @@ -55,8 +55,8 @@ export function createVesselSnapshotRenderer({ if (evicted) selection.clearVesselInspection({ evicted: true }); rendering.removeRecordPrimitives(record); }, - removed(mmsi) { - if (state.trailMmsi === mmsi) tracking.clearSelectedVesselTrail(); + removed(id) { + if (state.trailId === id) tracking.clearSelectedVesselTrail(); }, staleSelected(record) { cards.updateSelectedVesselHud(record); @@ -64,7 +64,7 @@ export function createVesselSnapshotRenderer({ }, ); // The selected vessel at the new time: its trail starts there. - if (replace && state.selectedRecord && state.trailMmsi) + if (replace && state.selectedRecord && state.trailId) tracking.startSelectedVesselTrail(state.selectedRecord); state.lastVisibilityUpdate = 0; rendering.updateVisibility(true); diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index 35e336edc..8875d706e 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -86,14 +86,14 @@ export function createVesselState({ source, services }) { activeFocusCount: 0, activeLabelCount: 0, selectedRecord: null, - /** @type {string|null} MMSI whose bridge the Cockpit camera occupies; its sprite and card stay hidden. */ - cockpitSubjectMmsi: null, + /** @type {string|null} Id of the vessel whose bridge the Cockpit camera occupies; its sprite and card stay hidden. */ + cockpitSubjectId: null, /** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */ trail: null, /** @type {Cesium.Cartesian3[]} Chronological trail vertices (oldest first) */ trailPositions: [], - /** @type {string|null} MMSI that owns the active selected-vessel trail. */ - trailMmsi: null, + /** @type {string|null} Id of the vessel that owns the active selected-vessel trail. */ + trailId: null, /** @type {number} Monotonic token — invalidates in-flight backfill responses */ trailBackfillToken: 0, trailAbort: null, diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js index 6cab17086..436c451d4 100644 --- a/src/layers/vessels/testing.js +++ b/src/layers/vessels/testing.js @@ -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 { diff --git a/src/layers/vessels/tracking.js b/src/layers/vessels/tracking.js index 8a8a5c30c..dfc2aae55 100644 --- a/src/layers/vessels/tracking.js +++ b/src/layers/vessels/tracking.js @@ -86,7 +86,7 @@ export function createTracking({ state.trailAbort?.abort(); state.trailAbort = new AbortController(); state.trailBackfillToken += 1; - state.trailMmsi = record.mmsi; + state.trailId = record.id; state.trailPositions = []; const current = vesselTrailPosition(record); if (current) state.trailPositions.push(current); @@ -97,25 +97,21 @@ export function createTracking({ }); } if (state.trail) state.trail.setPositions(state.trailPositions); - backfillVesselTrail( - record.mmsi, - state.trailBackfillToken, - record.reference, - ); + backfillVesselTrail(record.id, state.trailBackfillToken, record.reference); } /** - * Fire-and-forget backfill from the server-side per-MMSI ring buffer + * Fire-and-forget backfill from the server-side per-vessel ring buffer * (PRD F3 — "recent path" since server boot, not voyage history). Older * samples are spliced AHEAD of the live accumulation, capped at * TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/malformed) * silently keeps the live-only trail. - * @param {string} mmsi - MMSI of the selected vessel. + * @param {string} id - Key of the selected vessel. * @param {number} token - Backfill token captured at request time. * @returns {Promise} */ - async function backfillVesselTrail(mmsi, token, reference = mmsi) { + async function backfillVesselTrail(id, token, reference = id) { const owner = state.trailAbort; // Following the world clock, the track ends at the world's time. const atMs = followsClock(vesselState) ? vesselState.worldNow() : undefined; @@ -139,7 +135,7 @@ export function createTracking({ token !== state.trailBackfillToken ) return; - if (state.trailMmsi !== mmsi) return; + if (state.trailId !== id) return; const older = []; for (const sample of samples) { @@ -198,7 +194,7 @@ export function createTracking({ state.trailAbort?.abort(); state.trailAbort = null; state.trailBackfillToken += 1; - state.trailMmsi = null; + state.trailId = null; state.trailPositions = []; if (state.trail) state.trail.clear(); } diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index d892ddf1c..ab26f6a3e 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -1,43 +1,124 @@ import { ShareRestoration } from './ui/shareRestoration.js'; -import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; +import { + readShellSource, + shellMethod, +} from './testSupport/readShellSource.mjs'; import { renderApplicationHtml } from './testSupport/applicationHtml.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -const retrySite = (stats = {}) => ({ id: 'military-installations', name: 'Mapped Installations', enabled: true, - stats: { status: 'unavailable', error: 'Unavailable', retryAt: Date.now() + 30000, ...stats } }); +const retrySite = (stats = {}) => ({ + id: 'military-installations', + name: 'Mapped Installations', + enabled: true, + stats: { + status: 'unavailable', + error: 'Unavailable', + retryAt: Date.now() + 30000, + ...stats, + }, +}); test('installation retry remains visible after failure dwell without a false spinner', () => { const summary = aggregateLayerLoading([retrySite()]); - const view = presentLoadingFeedback(createLoadingFeedbackState(), summary, 100); + const view = presentLoadingFeedback( + createLoadingFeedbackState(), + summary, + 100, + ); assert.equal(view.state, 'retry'); assert.equal(view.label, 'OVERPASS TEMPORARILY UNAVAILABLE'); assert.match(view.detail, /retrying in 30s/); - assert.equal(presentLoadingFeedback(createLoadingFeedbackState(), aggregateLayerLoading([{ ...retrySite(), enabled: false }]), 100), null); + assert.equal( + presentLoadingFeedback( + createLoadingFeedbackState(), + aggregateLayerLoading([{ ...retrySite(), enabled: false }]), + 100, + ), + null, + ); }); test('an installation retry never conceals another participant failure', () => { - const state = { visible: true, phase: 'terminal', terminal: 'error', activeIds: ['military-installations', 'flights'] }; - const summary = aggregateLayerLoading([retrySite(), { id: 'flights', enabled: true, stats: { error: 'Failed' } }]); - assert.equal(presentLoadingFeedback(state, summary, 100).label, 'LOAD FAILED'); + const state = { + visible: true, + phase: 'terminal', + terminal: 'error', + activeIds: ['military-installations', 'flights'], + }; + const summary = aggregateLayerLoading([ + retrySite(), + { id: 'flights', enabled: true, stats: { error: 'Failed' } }, + ]); + assert.equal( + presentLoadingFeedback(state, summary, 100).label, + 'LOAD FAILED', + ); const healthyNow = aggregateLayerLoading([retrySite()]); - assert.equal(presentLoadingFeedback({ ...state, failedEventIds: ['flights'] }, healthyNow, 100).label, 'LOAD FAILED'); + assert.equal( + presentLoadingFeedback( + { ...state, failedEventIds: ['flights'] }, + healthyNow, + 100, + ).label, + 'LOAD FAILED', + ); }); test('a fresh installation retry can finish successfully without inheriting the old error', () => { - let state = { ...createLoadingFeedbackState(), phase: 'terminal', terminal: 'error', visible: true, activeIds: ['military-installations'] }; - const loading = aggregateLayerLoading([retrySite({ status: 'loading', error: null, loading: true, retryAt: 0, retrying: true })]); + let state = { + ...createLoadingFeedbackState(), + phase: 'terminal', + terminal: 'error', + visible: true, + activeIds: ['military-installations'], + }; + const loading = aggregateLayerLoading([ + retrySite({ + status: 'loading', + error: null, + loading: true, + retryAt: 0, + retrying: true, + }), + ]); state = reduceLoadingFeedback(state, loading, 1000); state = reduceLoadingFeedback(state, loading, 1200); - assert.equal(presentLoadingFeedback(state, loading, 1200).label, 'RETRYING MAPPED SITES'); - const done = aggregateLayerLoading([retrySite({ status: 'ready', error: null, loading: false, retryAt: 0, retrying: false, count: 3 })]); + assert.equal( + presentLoadingFeedback(state, loading, 1200).label, + 'RETRYING MAPPED SITES', + ); + const done = aggregateLayerLoading([ + retrySite({ + status: 'ready', + error: null, + loading: false, + retryAt: 0, + retrying: false, + count: 3, + }), + ]); state = reduceLoadingFeedback(state, done, 1500); - assert.equal(presentLoadingFeedback(state, done, 1500).label, 'MAPPED SITES LOADED'); + assert.equal( + presentLoadingFeedback(state, done, 1500).label, + 'MAPPED SITES LOADED', + ); }); test('turning off a retrying installation layer does not report the old fetch failure as a disable failure', () => { - const stopping = aggregateLayerLoading([{ ...retrySite(), lifecycleState: 'disabling' }]); - let state = reduceLoadingFeedback(createLoadingFeedbackState(), stopping, 1000); + const stopping = aggregateLayerLoading([ + { ...retrySite(), lifecycleState: 'disabling' }, + ]); + let state = reduceLoadingFeedback( + createLoadingFeedbackState(), + stopping, + 1000, + ); state = reduceLoadingFeedback(state, stopping, 1200); - const off = aggregateLayerLoading([{ ...retrySite({ status: 'idle', error: null, retryAt: 0 }), enabled: false }]); + const off = aggregateLayerLoading([ + { + ...retrySite({ status: 'idle', error: null, retryAt: 0 }), + enabled: false, + }, + ]); state = reduceLoadingFeedback(state, off, 1400); assert.equal(presentLoadingFeedback(state, off, 1400).label, 'LIVE DATA OFF'); }); @@ -58,9 +139,19 @@ import { } from './loadingFeedback.js'; test('universal status notices reuse the standard failure dwell', () => { - const notice = createGlobalStatusNotice('Shared satellite is unavailable', 1000); - assert.equal(notice.hideAt, null, 'finite dwell waits until first presentation'); - assert.equal(presentGlobalStatusNotice(notice, 1001).label, 'Shared satellite is unavailable'); + const notice = createGlobalStatusNotice( + 'Shared satellite is unavailable', + 1000, + ); + assert.equal( + notice.hideAt, + null, + 'finite dwell waits until first presentation', + ); + assert.equal( + presentGlobalStatusNotice(notice, 1001).label, + 'Shared satellite is unavailable', + ); assert.equal(notice.hideAt, 1001 + LOADING_FAILURE_DWELL_MS); assert.deepEqual(presentGlobalStatusNotice(notice, notice.hideAt - 1), { state: 'error', @@ -86,10 +177,16 @@ test('acquiring notices persist without a dwell until explicitly cleared', () => test('deferred terminal notices lose ownership to newer acquisition epochs and disposal', () => { assert.equal(canPresentDeferredStatusNotice(4, 4, false), true); - assert.equal(canPresentDeferredStatusNotice(4, 5, false), false, - 'a newer ACQUIRING epoch blocks the older deferred failure'); - assert.equal(canPresentDeferredStatusNotice(5, 5, true), false, - 'disposal blocks even the current deferred notice'); + assert.equal( + canPresentDeferredStatusNotice(4, 5, false), + false, + 'a newer ACQUIRING epoch blocks the older deferred failure', + ); + assert.equal( + canPresentDeferredStatusNotice(5, 5, true), + false, + 'disposal blocks even the current deferred notice', + ); }); test('share-follow failures use the universal top-center status instead of the bottom toast', () => { @@ -97,15 +194,22 @@ test('share-follow failures use the universal top-center status instead of the b const present = shellMethod('_presentAfterStartupCover').toString(); assert.match( handler, - /this\.initialRestorePromise\.then\(\(\) =>\s*this\._presentAfterStartupCover\(message, noticeGeneration\),?\s*\)/, + /this\.initialRestorePromise\.then\(\(\) =>\s*this\._presentAfterStartupCover\(notice, noticeGeneration\),?\s*\)/, ); - assert.match(present, /this\.showStatus\(message\)/); + assert.match(present, /this\.showStatus\(notice\.message\)/); + assert.match(present, /state: 'last-known'/); assert.match(present, /this\._lifetime\.frame\(\(\) => \{/); - assert.match(present, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); - assert.match(present, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); + assert.match( + present, + /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/, + ); + assert.match( + present, + /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/, + ); assert.match(present, /canPresentDeferredStatusNotice\(/); for (const source of [handler, present]) { - assert.doesNotMatch(source, /this\._showToast\(message\)/); + assert.doesNotMatch(source, /this\._showToast\(/); assert.doesNotMatch(source, /pushCockpitSignal/); } assert.match(handler, /result\.classification === 'pending'/); @@ -118,51 +222,94 @@ test('share-follow failures use the universal top-center status instead of the b }); test('universal notice masks active loading only for its own fixed dwell', () => { - const summary = aggregateLayerLoading([{ id: 'satellites', name: 'Satellites', lifecycleState: 'enabling' }]); + const summary = aggregateLayerLoading([ + { id: 'satellites', name: 'Satellites', lifecycleState: 'enabling' }, + ]); let loading = reduceLoadingFeedback(createLoadingFeedbackState(), summary, 0); loading = reduceLoadingFeedback(loading, summary, 200); - const notice = createGlobalStatusNotice('Shared satellite is unavailable', 300); + const notice = createGlobalStatusNotice( + 'Shared satellite is unavailable', + 300, + ); - assert.equal(presentGlobalLoadingStatus(notice, loading, summary, 301).label, 'Shared satellite is unavailable'); - assert.equal(presentGlobalLoadingStatus(notice, loading, summary, notice.hideAt).label, 'LOADING LIVE DATA'); + assert.equal( + presentGlobalLoadingStatus(notice, loading, summary, 301).label, + 'Shared satellite is unavailable', + ); + assert.equal( + presentGlobalLoadingStatus(notice, loading, summary, notice.hideAt).label, + 'LOADING LIVE DATA', + ); }); test('terminal failure preempts a finite notice, whose full dwell starts afterward', () => { - const active = aggregateLayerLoading([{ id: 'satellites', name: 'Satellites', lifecycleState: 'enabling' }]); + const active = aggregateLayerLoading([ + { id: 'satellites', name: 'Satellites', lifecycleState: 'enabling' }, + ]); let loading = reduceLoadingFeedback(createLoadingFeedbackState(), active, 0); loading = reduceLoadingFeedback(loading, active, 200); loading = reduceLoadingFeedback(loading, aggregateLayerLoading([]), 300, { - type: 'visibility-failed', layerId: 'satellites', error: new Error('offline'), + type: 'visibility-failed', + layerId: 'satellites', + error: new Error('offline'), }); - const notice = createGlobalStatusNotice('Shared satellite is unavailable', 400); + const notice = createGlobalStatusNotice( + 'Shared satellite is unavailable', + 400, + ); assert.equal( - presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), 401).label, + presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), 401) + .label, 'LOAD FAILED', ); - assert.equal(notice.hideAt, null, 'masked finite notice has not started its dwell'); + assert.equal( + notice.hideAt, + null, + 'masked finite notice has not started its dwell', + ); loading = reduceLoadingFeedback(loading, aggregateLayerLoading([]), 5300); assert.equal( - presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), 5300).label, + presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), 5300) + .label, 'Shared satellite is unavailable', ); assert.equal(notice.hideAt, 5300 + LOADING_FAILURE_DWELL_MS); assert.equal( - presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), notice.hideAt - 1).label, + presentGlobalLoadingStatus( + notice, + loading, + aggregateLayerLoading([]), + notice.hideAt - 1, + ).label, 'Shared satellite is unavailable', ); - assert.equal(presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), notice.hideAt), null); + assert.equal( + presentGlobalLoadingStatus( + notice, + loading, + aggregateLayerLoading([]), + notice.hideAt, + ), + null, + ); }); test('persistent acquisition never hides an unrelated manager failure', () => { - const active = aggregateLayerLoading([{ - id: 'traffic', name: 'Street Traffic', lifecycleState: 'enabling', - }]); + const active = aggregateLayerLoading([ + { + id: 'traffic', + name: 'Street Traffic', + lifecycleState: 'enabling', + }, + ]); const idle = aggregateLayerLoading([]); let loading = reduceLoadingFeedback(createLoadingFeedbackState(), active, 0); loading = reduceLoadingFeedback(loading, active, 200); loading = reduceLoadingFeedback(loading, idle, 300, { - type: 'visibility-failed', layerId: 'traffic', error: new Error('offline'), + type: 'visibility-failed', + layerId: 'traffic', + error: new Error('offline'), }); const acquiring = createGlobalStatusNotice('ACQUIRING', 100, { state: 'acquiring', @@ -176,71 +323,155 @@ test('persistent acquisition never hides an unrelated manager failure', () => { detail: '', }); assert.equal( - presentGlobalLoadingStatus(acquiring, loading, idle, 300 + LOADING_FAILURE_DWELL_MS - 1).label, + presentGlobalLoadingStatus( + acquiring, + loading, + idle, + 300 + LOADING_FAILURE_DWELL_MS - 1, + ).label, 'LOAD FAILED', ); - loading = reduceLoadingFeedback(loading, idle, 300 + LOADING_FAILURE_DWELL_MS); + loading = reduceLoadingFeedback( + loading, + idle, + 300 + LOADING_FAILURE_DWELL_MS, + ); assert.equal( - presentGlobalLoadingStatus(acquiring, loading, idle, 300 + LOADING_FAILURE_DWELL_MS).label, + presentGlobalLoadingStatus( + acquiring, + loading, + idle, + 300 + LOADING_FAILURE_DWELL_MS, + ).label, 'ACQUIRING', 'the still-owned acquisition resumes only after the full failure dwell is visible', ); }); test('replacement, repetition, and hidden-tab elapsed time use the newest fixed deadline', () => { - const first = createGlobalStatusNotice('Shared satellite is unavailable', 100); - const repeated = createGlobalStatusNotice('Shared satellite is unavailable', 200); - const replacement = createGlobalStatusNotice('Shared satellite follow expired', 300); + const first = createGlobalStatusNotice( + 'Shared satellite is unavailable', + 100, + ); + const repeated = createGlobalStatusNotice( + 'Shared satellite is unavailable', + 200, + ); + const replacement = createGlobalStatusNotice( + 'Shared satellite follow expired', + 300, + ); presentGlobalStatusNotice(first, 100); presentGlobalStatusNotice(repeated, 200); presentGlobalStatusNotice(replacement, 300); - assert.ok(repeated.hideAt > first.hideAt, 'a repeated event is a new accessible notice epoch'); - assert.equal(presentGlobalStatusNotice(replacement, replacement.hideAt - 1).label, 'Shared satellite follow expired'); - assert.equal(presentGlobalStatusNotice(replacement, replacement.hideAt), null, - 'elapsed wall time while hidden expires the notice instead of replaying it'); + assert.ok( + repeated.hideAt > first.hideAt, + 'a repeated event is a new accessible notice epoch', + ); + assert.equal( + presentGlobalStatusNotice(replacement, replacement.hideAt - 1).label, + 'Shared satellite follow expired', + ); + assert.equal( + presentGlobalStatusNotice(replacement, replacement.hideAt), + null, + 'elapsed wall time while hidden expires the notice instead of replaying it', + ); }); test('universal notice lifecycle clears on dispose and uses the one top-center live region', async () => { const ui = readShellSource(); - const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); + const html = await renderApplicationHtml( + readFileSync(new URL('../index.html', import.meta.url), 'utf8'), + ); const disposeStart = ui.indexOf(' async dispose() {'); const disposeEnd = ui.indexOf('\n }\n', disposeStart); const dispose = ui.slice(disposeStart, disposeEnd); assert.match(dispose, /this\._feedback\.clearLoadingStatus\(\);/); assert.match(dispose, /this\._shareRestoration\.destroy\(\)/); - assert.match(ShareRestoration.prototype.destroy.toString(), /this\._shareTrackingNoticeGeneration \+= 1;/); - assert.match(html, /