From 52507a075569cb5b7868b80c443e972f3a2fd39c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 11:07:13 -0800 Subject: [PATCH] Draw no buoy for a virtual aid to navigation A virtual aid (AIS message 21's VirtualAtoN flag) is a mark broadcast where nothing physical stands, so drawing it as a buoy showed a mark that is not there. The vessel source record now carries `virtual` (false unless a source says true; any source may set it), AISStream sets it from VirtualAtoN, and the vessel layer keeps such an aid's disc: vesselModelSpec returns null for it and the layer gathers no model item for it. Its card, selected card and readout add VIRTUAL after its kind. --- changelog.d/1733-buoy-models.md | 4 +++- docs/CURRENT-STATE.md | 2 +- server/providers/vessels/ais-store.js | 3 +++ src/data/aisLiveVessels.test.mjs | 16 ++++++++++++++++ src/data/aisStreamSentinels.test.mjs | 22 ++++++++++++++++++++++ src/data/fleetModels.js | 15 +++++++++++---- src/data/fleetModels.test.mjs | 9 +++++++++ src/data/vesselTypes.js | 8 +++++--- src/data/vesselTypes.test.mjs | 5 +++++ src/i18n/locales/ar.json | 1 + src/i18n/locales/de.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/he.json | 1 + src/i18n/locales/it.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/pt.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/sv.json | 1 + src/i18n/locales/uk.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-Hant.json | 1 + src/i18n/same-as-english.json | 6 ++++-- src/layers/vessels/cards.js | 14 ++++++++++++-- src/layers/vessels/ingestion.js | 1 + src/layers/vessels/ownership.test.mjs | 23 +++++++++++++++++++++++ src/layers/vessels/records.js | 3 +++ src/layers/vessels/rendering.js | 16 ++++++++++++---- src/sources/live/contract.js | 4 +++- src/sources/live/contract.test.mjs | 12 ++++++++++++ src/sources/live/vessels.js | 2 ++ 31 files changed, 160 insertions(+), 18 deletions(-) diff --git a/changelog.d/1733-buoy-models.md b/changelog.d/1733-buoy-models.md index 656a93374..a2de9e612 100644 --- a/changelog.d/1733-buoy-models.md +++ b/changelog.d/1733-buoy-models.md @@ -6,4 +6,6 @@ show its kind of mark with no speed or heading. AISStream now subscribes to aid-to-navigation reports and names each by its kind of mark and its full name; the feed lists every ship ahead of any aid, and `vessels_in_area` - counts ships only (samehkhamis, #0000). + counts ships only. A virtual aid (a mark broadcast where nothing physical + stands) keeps its disc, is never drawn as a buoy, and its card says it is + virtual; vessel sources may mark any record `virtual` (samehkhamis, #1733). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0b301991b..fc00c5934 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -7430,7 +7430,7 @@ pins and share links already supply complete boxes. 30-second backoff ahead of the current clock are ignored. A smaller rollback can retain a bounded retry delay of up to 30 seconds. - Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Every aircraft model draws its mesh's own colours (`aircraftModelBlend`, `src/data/aircraftModelLighting.js`): Cesium's `ColorBlendMode.HIGHLIGHT` with white, in Flights, Military Flights and Local ADS-B alike; the tracked colour (cyan, or amber in Military Flights) and the emergency red multiply into the mesh, so its paint still shows. IR boost draws them flat white (`MIX` at `1.0`). Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. -- Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. An aid to navigation is not a hull: a vessel record whose type names a buoy, beacon, mark, RACON or light, or whose MMSI is an aid-to-navigation station's (`99MIDxxxx`), is `isAidToNavigation` (`src/data/vesselTypes.js`) and is drawn as the set's `buoys` model of the IALA mark its type names (`aidToNavigationModel`, `src/data/fleetModels.js`: cardinal N/E/S/W, port or starboard hand, preferred channel by its side, safe water, isolated danger, special mark; a beacon, light, light vessel, RACON or reference point with no mark of its own is the special mark), else a red can (`DEFAULT_AID_MODEL`), at its built size and facing north whatever heading it reports. A type is an aid's when it is one of the 32 kinds an aid report names or uses a word for one (buoy, beacon, cardinal, safe water, RACON, lighthouse and so on); a vessel that works on aids (`Buoy Tender`, `Buoy Layer`) is not one. Its icon is a bone-white ringed disc (`AID_TO_NAVIGATION_STYLE`, `src/data/vesselLabels.js`) instead of a chevron, never rotated, and its card, selected card and HUD readout (`src/layers/vessels/cards.js`) show its kind alone, whole, in that colour: no speed or heading, since a fixed mark has neither. AISStream subscribes to `AidsToNavigationReport` (message 21) by default, and the server names each report's aid type code (0 to 31) as its kind (`aidToNavigationTypeName`: `20` is `Cardinal Mark N`) rather than reading it as a ship type, and its name runs on into the report's `NameExtension`. `aisStreamRows` (`server/providers/vessels/ais-store.js`) puts every vessel ahead of every aid, so a capped request (the layer asks for 12,000 rows) never gives up a ship for a buoy. The `vessels_in_area` tool (and the brief built on it) counts ships only: it leaves aids out of its rows, counts, breakdowns and speed stats. A transit vehicle's model follows its layer mode (bus, tram, subway, rail or ferry; a vehicle of `unknown` mode keeps its icon), a train's its rail category and mode (rail, metro or tram). Neither the transit nor the rail sources carry a GTFS `route_type` or a finer mode (monorail, gondola, funicular, cable car), so the set's models for those and their `transit.elevation` lifts are not reached by any live source today. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay. +- Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. An aid to navigation is not a hull: a vessel record whose type names a buoy, beacon, mark, RACON or light, or whose MMSI is an aid-to-navigation station's (`99MIDxxxx`), is `isAidToNavigation` (`src/data/vesselTypes.js`) and is drawn as the set's `buoys` model of the IALA mark its type names (`aidToNavigationModel`, `src/data/fleetModels.js`: cardinal N/E/S/W, port or starboard hand, preferred channel by its side, safe water, isolated danger, special mark; a beacon, light, light vessel, RACON or reference point with no mark of its own is the special mark), else a red can (`DEFAULT_AID_MODEL`), at its built size and facing north whatever heading it reports. A type is an aid's when it is one of the 32 kinds an aid report names or uses a word for one (buoy, beacon, cardinal, safe water, RACON, lighthouse and so on); a vessel that works on aids (`Buoy Tender`, `Buoy Layer`) is not one. Its icon is a bone-white ringed disc (`AID_TO_NAVIGATION_STYLE`, `src/data/vesselLabels.js`) instead of a chevron, never rotated, and its card, selected card and HUD readout (`src/layers/vessels/cards.js`) show its kind alone, whole, in that colour: no speed or heading, since a fixed mark has neither. AISStream subscribes to `AidsToNavigationReport` (message 21) by default, and the server names each report's aid type code (0 to 31) as its kind (`aidToNavigationTypeName`: `20` is `Cardinal Mark N`) rather than reading it as a ship type, and its name runs on into the report's `NameExtension`. A virtual aid (the report's `VirtualAtoN` flag: a mark broadcast where nothing physical stands) carries `virtual: true` on its row, and on its vessel source record (the live-source contract's `virtual`, `src/sources/live/contract.js`, false unless a source says true; any source may set it). It keeps its disc but never gets a model (`vesselModelSpec` returns null for it, and the vessel layer gathers no model item for it), and its card and readout add `VIRTUAL` after its kind. `aisStreamRows` (`server/providers/vessels/ais-store.js`) puts every vessel ahead of every aid, so a capped request (the layer asks for 12,000 rows) never gives up a ship for a buoy. The `vessels_in_area` tool (and the brief built on it) counts ships only: it leaves aids out of its rows, counts, breakdowns and speed stats. A transit vehicle's model follows its layer mode (bus, tram, subway, rail or ferry; a vehicle of `unknown` mode keeps its icon), a train's its rail category and mode (rail, metro or tram). Neither the transit nor the rail sources carry a GTFS `route_type` or a finer mode (monorail, gondola, funicular, cable car), so the set's models for those and their `transit.elevation` lifts are not reached by any live source today. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay. - **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). The models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload. - The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. - **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design. diff --git a/server/providers/vessels/ais-store.js b/server/providers/vessels/ais-store.js index e6e8b53d7..6c4d8a57a 100644 --- a/server/providers/vessels/ais-store.js +++ b/server/providers/vessels/ais-store.js @@ -143,6 +143,9 @@ export function ingestAisStreamEnvelope(envelope) { // Use the AIS message's own report time, not server ingest wall-clock — // trail spacing and dead reckoning depend on true fix epochs. last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc), + // A virtual aid is broadcast where nothing physical stands. Only such a + // row carries the field, so ship rows stay their size. + ...(aidReport && message.VirtualAtoN === true && { virtual: true }), _updatedAt: Date.now(), }); diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 5cfb68add..6608eb055 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1916,7 +1916,23 @@ test('the HUD of a selected aid to navigation shows no speed or heading', () => assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true); assert.match(harness.hud.readout.text, /\nPort hand Mark\n/); assert.doesNotMatch(harness.hud.readout.text, /SPD|HDG|KT/); + harness.record.virtual = true; + aisLiveVesselsLayer.selectById(harness.record.mmsi); + assert.match(harness.hud.readout.text, /\nPort hand Mark · VIRTUAL\n/); } finally { harness.cleanup(); } }); + +test('a virtual aid to navigation card says it is virtual', () => { + const aid = makeRecord({ + mmsi: '993672001', + type: 'Isolated danger', + virtual: true, + }); + assert.deepEqual(buildVesselCard(aid).details, ['ISOLATED DANGER · VIRTUAL']); + assert.equal( + buildSelectedVesselCard(aid).details[0], + 'ISOLATED DANGER · VIRTUAL', + ); +}); diff --git a/src/data/aisStreamSentinels.test.mjs b/src/data/aisStreamSentinels.test.mjs index bb93020c4..563bf9f65 100644 --- a/src/data/aisStreamSentinels.test.mjs +++ b/src/data/aisStreamSentinels.test.mjs @@ -388,3 +388,25 @@ test('a capped row request keeps every vessel ahead of aids to navigation', () = aisStreamRows(vesselCount).every((row) => !row.mmsi.startsWith('99')), ); }); + +test('a virtual aid to navigation is marked virtual; a real one carries no flag', () => { + const aidReport = (mmsi, VirtualAtoN) => ({ + MessageType: 'AidsToNavigationReport', + Message: { + AidsToNavigationReport: { UserID: Number(mmsi), Type: 28, VirtualAtoN }, + }, + MetaData: { + MMSI: mmsi, + latitude: 37.79, + longitude: -122.52, + time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC', + }, + }); + ingestAisStreamEnvelope(aidReport('993672004', true)); + ingestAisStreamEnvelope(aidReport('993672005', false)); + assert.equal(rowFor('993672004').virtual, true); + assert.equal('virtual' in rowFor('993672005'), false); + // A later real report of the same aid drops the flag. + ingestAisStreamEnvelope(aidReport('993672004', false)); + assert.equal('virtual' in rowFor('993672004'), false); +}); diff --git a/src/data/fleetModels.js b/src/data/fleetModels.js index 6c4081324..69ab2a678 100644 --- a/src/data/fleetModels.js +++ b/src/data/fleetModels.js @@ -443,13 +443,20 @@ export function aircraftModelSpec( * ({@link isAidToNavigation}: its type names a buoy, beacon, mark, RACON or * light, or its MMSI is an aid's) is the buoy of its mark * ({@link aidToNavigationModel}), else {@link DEFAULT_AID_MODEL}, drawn as - * built and flagged `fixed`: it has no heading to turn to. + * built and flagged `fixed`: it has no heading to turn to. A virtual aid + * (`virtual`) is broadcast where nothing physical stands, so it has no model. * @param {{catalog: object, types: object}} set * @param {{type?: string|number|null, mmsi?: string|number|null, - * length?: number|null, beam?: number|null, name?: string|null}} vessel - * @returns {object} {@link fleetModelSpec}, with `fixed` for an aid + * virtual?: boolean, length?: number|null, beam?: number|null, + * name?: string|null}} vessel + * @returns {object|null} {@link fleetModelSpec}, with `fixed` for an aid; + * null for a virtual aid */ -export function vesselModelSpec(set, { type, mmsi, length, beam, name } = {}) { +export function vesselModelSpec( + set, + { type, mmsi, virtual, length, beam, name } = {}, +) { + if (virtual === true) return null; if (isAidToNavigation({ type, mmsi })) { const model = aidToNavigationModel(type); const known = model && set.catalog[model] ? model : DEFAULT_AID_MODEL; diff --git a/src/data/fleetModels.test.mjs b/src/data/fleetModels.test.mjs index 66ff49f3a..d48e744c1 100644 --- a/src/data/fleetModels.test.mjs +++ b/src/data/fleetModels.test.mjs @@ -423,6 +423,15 @@ test('aids to navigation are the buoy of the mark their type names', () => { assert.equal(unnamed.model, DEFAULT_AID_MODEL); assert.equal(unnamed.fixed, true); assert.equal(aidToNavigationModel('Fixed structure off shore'), undefined); + // A virtual aid is broadcast where nothing stands: it keeps its disc. + assert.equal( + vesselModelSpec(set, { + type: 'Cardinal Mark N', + mmsi: '993672001', + virtual: true, + }), + null, + ); // Vessels, including those that work on buoys, keep their hulls. for (const vessel of [ { type: 'Buoy Tender', mmsi: '366999001', length: 60 }, diff --git a/src/data/vesselTypes.js b/src/data/vesselTypes.js index 5b9dd22b1..742187548 100644 --- a/src/data/vesselTypes.js +++ b/src/data/vesselTypes.js @@ -140,11 +140,13 @@ function typeNamesAid(text) { * Whether a vessel record is an aid to navigation (a buoy, beacon, mark, * RACON or light) rather than a vessel: its type is one of the kinds an aid * report names or uses a word for one, or its MMSI is an aid-to-navigation - * station's. A numeric AIS ship type never names one. - * @param {{type?: string|number|null, mmsi?: string|number|null}} vessel + * station's, or it is a virtual aid. A numeric AIS ship type never names one. + * @param {{type?: string|number|null, mmsi?: string|number|null, + * virtual?: boolean}} vessel * @returns {boolean} */ -export function isAidToNavigation({ type, mmsi } = {}) { +export function isAidToNavigation({ type, mmsi, virtual } = {}) { + if (virtual === true) return true; const text = typeof type === 'string' ? type.trim() : String(type ?? ''); if (text && typeNamesAid(text)) return true; if (mmsi === null || mmsi === undefined || mmsi === '') return false; diff --git a/src/data/vesselTypes.test.mjs b/src/data/vesselTypes.test.mjs index 3a7e3ca2d..65816743c 100644 --- a/src/data/vesselTypes.test.mjs +++ b/src/data/vesselTypes.test.mjs @@ -36,6 +36,11 @@ test('aids to navigation are known by their type, else their MMSI', () => { 'Emergency wreck marking buoy', ]) assert.equal(isAidToNavigation({ type, mmsi: '' }), true, type); + // A virtual aid is an aid whatever its type or MMSI. + assert.equal( + isAidToNavigation({ type: '', mmsi: '', virtual: true }), + true, + ); // AIS aid-to-navigation stations are numbered 99MIDxxxx. assert.equal(isAidToNavigation({ type: '', mmsi: '993672001' }), true); assert.equal(isAidToNavigation({ type: '0', mmsi: ' 992351234 ' }), true); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 0eeb41a7c..44024e10f 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -777,6 +777,7 @@ "OTHER": "أخرى", "VESSEL": "سفينة", "AID TO NAVIGATION": "مساعدة ملاحية", + "VIRTUAL": "افتراضي", "POS: LIVE": "الموضع: مباشر", "POS: {time}Z": "الموضع: {time}Z", "AIS: --": "AIS: --", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index bc9280d70..cdb669452 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -417,6 +417,7 @@ "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "SCHIFF", "AID TO NAVIGATION": "SEEZEICHEN", + "VIRTUAL": "VIRTUELL", "POS: LIVE": "POS: LIVE", "POS: {time}Z": "POS: {time}Z", "awaiting first AIS position…": "warten auf erste AIS-Position…", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 829ac7ea6..b0d10ae06 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -651,6 +651,7 @@ "OTHER": "OTRO", "VESSEL": "BUQUE", "AID TO NAVIGATION": "AYUDA A LA NAVEGACIÓN", + "VIRTUAL": "VIRTUAL", "POS: LIVE": "POS: EN VIVO", "POS: {time}Z": "POS: {time}Z", "AIS: --": "AIS: --", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a4d3a831f..ffe522ad9 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1292,6 +1292,7 @@ "{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}", "VESSEL": "NAVIRE", "AID TO NAVIGATION": "AIDE À LA NAVIGATION", + "VIRTUAL": "VIRTUEL", "{speed}KT": "{speed} ND", "{heading}DEG": "{heading}°", "POS: LIVE": "POS : DIRECT", diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index f27f4279b..cfe612894 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -1150,6 +1150,7 @@ "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "אונייה", "AID TO NAVIGATION": "עזר ניווט", + "VIRTUAL": "וירטואלי", "{speed}KT": "{speed} קשר", "{heading}DEG": "{heading}°", "POS: LIVE": "POS: חי", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 9ee245256..e8d939278 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1292,6 +1292,7 @@ "{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}", "VESSEL": "NAVE", "AID TO NAVIGATION": "AIUTO ALLA NAVIGAZIONE", + "VIRTUAL": "VIRTUALE", "{speed}KT": "{speed}KT", "{heading}DEG": "{heading}°", "POS: LIVE": "POS: LIVE", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 488f7b81e..8039831d8 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1254,6 +1254,7 @@ "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "船舶", "AID TO NAVIGATION": "航路標識", + "VIRTUAL": "仮想", "{speed}KT": "{speed} kt", "{heading}DEG": "{heading}°", "POS: LIVE": "POS: ライブ", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 5944dbc6b..18b6d1dd2 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1290,6 +1290,7 @@ "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "EMBARCAÇÃO", "AID TO NAVIGATION": "AUXÍLIO À NAVEGAÇÃO", + "VIRTUAL": "VIRTUAL", "{speed}KT": "{speed} KT", "{heading}DEG": "{heading}°", "POS: LIVE": "POS: AO VIVO", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 5065907c4..b58d78d26 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -395,6 +395,7 @@ "{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}", "VESSEL": "СУДНО", "AID TO NAVIGATION": "СРЕДСТВО НАВИГАЦИИ", + "VIRTUAL": "ВИРТУАЛЬНЫЙ", "AIS: --": "AIS: --", "awaiting first AIS position…": "ожидание первой позиции AIS…", "awaiting usable AIS positions…": "ожидание пригодных позиций AIS…", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 2eebc0df0..bd1f25a7e 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -383,6 +383,7 @@ "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "FARTYG", "AID TO NAVIGATION": "SJÖMÄRKE", + "VIRTUAL": "VIRTUELL", "POS: LIVE": "POS: LIVE", "POS: {time}Z": "POS: {time}Z", "awaiting first AIS position…": "väntar på första AIS-positionen …", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 8face8fd5..9506d0a31 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1310,6 +1310,7 @@ "{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}", "VESSEL": "СУДНО", "AID TO NAVIGATION": "ЗАСІБ НАВІГАЦІЇ", + "VIRTUAL": "ВІРТУАЛЬНИЙ", "{speed}KT": "{speed} ВУЗ", "{heading}DEG": "{heading}°", "POS: LIVE": "ПОЗ.: ОНЛАЙН", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index d8bcb9d63..f34c36028 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1230,6 +1230,7 @@ "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "船舶", "AID TO NAVIGATION": "助航标志", + "VIRTUAL": "虚拟", "{speed}KT": "{speed} kn", "{heading}DEG": "{heading}°", "POS: LIVE": "POS:实时", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index cf782e389..0e01c7773 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1225,6 +1225,7 @@ "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "船舶", "AID TO NAVIGATION": "助航標誌", + "VIRTUAL": "虛擬", "{speed}KT": "{speed} kn", "{heading}DEG": "{heading}°", "POS: LIVE": "POS:實時", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 1ab9b364f..b8a7eeb61 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -411,7 +411,8 @@ "MMSI: {id}", "NORAD: {id}", "Regional", - "{datasets} · {tiles} · {packs}" + "{datasets} · {tiles} · {packs}", + "VIRTUAL" ], "fr": [ "1 min/s", @@ -1067,7 +1068,8 @@ "ICAO: {id}", "MMSI: {id}", "NORAD: {id}", - "Regional" + "Regional", + "VIRTUAL" ], "ru": [ "1900–1945", diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index f8371c372..f215c6b09 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -24,7 +24,10 @@ export function createCards({ text: [ t('AIS: {name}', { name: trimHudValue(record.name, 32) }), aid - ? trimHudValue(record.type || t('AID TO NAVIGATION'), 24) + ? aidKind( + trimHudValue(record.type || t('AID TO NAVIGATION'), 24), + record, + ) : t('{type} SPD: {speed} HDG: {heading}', { type: trimHudValue(record.type || t('VESSEL'), 24), speed: formatSpeed(record.speed), @@ -60,6 +63,7 @@ export function createCards({ const aid = isAidToNavigation(record); const type = vesselTypeShort(record); if (type) parts.push(type); + if (record.virtual === true) parts.push(t('VIRTUAL')); if (!aid && record.speed !== null && record.speed !== undefined) parts.push(formatSpeed(record.speed)); const direction = record.heading ?? record.course; @@ -95,7 +99,7 @@ export function createCards({ const direction = record.heading ?? record.course; const details = [ aid - ? vesselTypeShort(record) || t('AID TO NAVIGATION') + ? aidKind(vesselTypeShort(record) || t('AID TO NAVIGATION'), record) : [ vesselTypeShort(record) || t('VESSEL'), formatSpeed(record.speed), @@ -125,6 +129,12 @@ export function createCards({ }; } + /** An aid's kind, marked virtual when nothing physical stands there. */ + + function aidKind(kind, record) { + return record.virtual === true ? `${kind} · ${t('VIRTUAL')}` : kind; + } + /** A card's accent: an aid's disc colour, else its ship type's. */ function vesselAccent(record, aid) { diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js index c291c8e35..2eb2d34f0 100644 --- a/src/layers/vessels/ingestion.js +++ b/src/layers/vessels/ingestion.js @@ -218,6 +218,7 @@ export function createIngestion({ speed: record.speedMps == null ? null : record.speedMps / 0.514444, course: record.courseDeg, heading: record.headingDeg, + virtual: record.virtual === true, last_position_epoch: record.observedAtMs == null ? null : record.observedAtMs / 1000, last_position_UTC: diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 643991e9f..cffd22dbd 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -536,6 +536,29 @@ test('an aid to navigation is drawn as a disc, a vessel as its chevron', async ( return atob(primitive.image.split(',')[1]); }; assert.match(svgOf('993672001'), / [...primitives].find((p) => p.id?.id === id).id; + assert.equal(recordOf('993672001').virtual, false); + assert.equal(recordOf('366999002').virtual, false); assert.doesNotMatch(svgOf('993672001'), / { + const aid = { + ...observation('993672009'), + mmsi: '993672009', + type: 'Isolated danger', + virtual: true, + }; + const { layer, primitives } = setup({ + async getSnapshot() { + return snapshot([aid]); + }, + }); + await layer.update(); + const primitive = [...primitives].find((p) => p.id?.id === '993672009'); + assert.ok(primitive); + assert.equal(primitive.id.virtual, true); + assert.match(atob(primitive.image.split(',')[1]), /