From 279aedb2f48b1bcb684164044ab2de5f38995317 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 09:54:01 -0800 Subject: [PATCH 1/4] Draw aids to navigation as buoys A buoy, beacon, RACON, light or IALA mark in the vessel feed was drawn as a ship. A vessel record whose type names one, or whose MMSI is an aid-to-navigation station's (99MIDxxxx), is now isAidToNavigation and vesselModelSpec draws it as the fleet set's buoy of its mark (cardinal N/E/S/W, port or starboard hand, safe water, isolated danger, special mark; a red can when the type names no mark), at its built size and flagged fixed so its model does not turn to a heading. Its icon is a disc instead of a chevron. AISStream now subscribes to AidsToNavigationReport by default, and the server names each report's aid type code (0 to 31) as its kind instead of reading it as a ship type. --- .env.example | 2 +- changelog.d/0000-buoy-models.md | 7 +++ docs/CURRENT-STATE.md | 2 +- public/models/README.md | 6 ++ scripts/package-boundaries.json | 6 ++ server/providers/vessels/ais-live.js | 2 + server/providers/vessels/ais-store.js | 8 ++- src/data/aisStreamSentinels.test.mjs | 26 +++++++++ src/data/fleetModels.js | 67 +++++++++++++++++++++-- src/data/fleetModels.test.mjs | 54 ++++++++++++++++++ src/data/vesselTypes.js | 79 +++++++++++++++++++++++++++ src/data/vesselTypes.test.mjs | 47 ++++++++++++++++ src/layers/vessels/ownership.test.mjs | 24 ++++++++ src/layers/vessels/rendering.js | 29 ++++++++-- 14 files changed, 345 insertions(+), 14 deletions(-) create mode 100644 changelog.d/0000-buoy-models.md create mode 100644 src/data/vesselTypes.test.mjs diff --git a/.env.example b/.env.example index 2caf21d29..a9819f100 100644 --- a/.env.example +++ b/.env.example @@ -176,7 +176,7 @@ VITE_AIS_LIVE_LABEL_MAX_ROWS=900 AISSTREAM_API_KEY= # Optional AISStream subscription filters. # AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]] -# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport +# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport,AidsToNavigationReport # Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket # is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the # default worldwide subscription — a narrow filter above can be legitimately diff --git a/changelog.d/0000-buoy-models.md b/changelog.d/0000-buoy-models.md new file mode 100644 index 000000000..a41fd3957 --- /dev/null +++ b/changelog.d/0000-buoy-models.md @@ -0,0 +1,7 @@ +- Draw aids to navigation as buoys instead of ships: a buoy, beacon, RACON, + light or mark from the vessel feed (or any AIS station numbered 99MIDxxxx) + is now the fleet set's buoy of its IALA mark (cardinal, port or starboard + hand, safe water, isolated danger or special mark), standing still with no + heading, and its icon is a disc rather than a chevron. AISStream now + subscribes to aid-to-navigation reports and names each by its kind of mark + (samehkhamis, #0000). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 59077f153..74a9fd50e 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. 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. Its icon is a bone-white ringed disc instead of a chevron, never rotated. 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. 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/public/models/README.md b/public/models/README.md index 49d22b4c9..3f97820d3 100644 --- a/public/models/README.md +++ b/public/models/README.md @@ -19,6 +19,12 @@ How the layers use them: type's published length (a vessel to its reported AIS length and beam), so every model is drawn life size. Ground objects, balloons and obstacles in the `other` category are drawn at the size they are built. +- An aid to navigation (a vessel record whose type names a buoy, beacon, IALA + mark, RACON or light, or whose MMSI is an aid station's `99MIDxxxx`) is + drawn as the `buoys` model of its mark (cardinal north, east, south or west, + port or starboard hand, safe water, isolated danger, special mark), a red + can when its type names no mark, at its built size and with no heading + (`vesselModelSpec`, `aidToNavigationModel`). - An aircraft model's origin is its axis; it stands that model's scaled clearance above the ground. A ship's origin is its waterline, and a transit or rail vehicle's the road or rail top (a monorail or gondola is raised by diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 541968ed7..2242d7b2c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -251,6 +251,7 @@ "src/data/adsbLolFallback.js", "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js", + "src/data/vesselTypes.js", "src/sources/httpBody.js", "src/i18n/marker.js", "server/providers/acartia.js", @@ -1514,6 +1515,7 @@ "exports": ["./layers/flights"], "modules": [ "src/data/fleetModels.js", + "src/data/vesselTypes.js", "src/data/fleetTrailAnchors.js", "src/data/fleetModelInstances.js", "src/data/headingDegrees.js", @@ -1593,6 +1595,7 @@ "src/layers/flights/modelSpec.js", "src/layers/flights/policy.js", "src/data/fleetModels.js", + "src/data/vesselTypes.js", "src/data/fleetTrailAnchors.js", "src/data/fleetModelInstances.js", "src/data/headingDegrees.js", @@ -4817,6 +4820,7 @@ "src/data/aircraftClass.js", "src/data/aircraftEmergency.js", "src/data/fleetModels.js", + "src/data/vesselTypes.js", "src/data/fleetTrailAnchors.js" ], "external": [] @@ -5207,6 +5211,7 @@ "exports": ["./layers/transit/standalone"], "modules": [ "src/data/fleetModels.js", + "src/data/vesselTypes.js", "src/data/aircraftClass.js", "src/i18n/marker.js", "src/data/fleetModelInstances.js", @@ -5299,6 +5304,7 @@ "modules": [ "src/i18n/marker.js", "src/data/fleetModels.js", + "src/data/vesselTypes.js", "src/data/fleetModelInstances.js", "src/data/aircraftClass.js", "src/data/aircraftModelLighting.js", diff --git a/server/providers/vessels/ais-live.js b/server/providers/vessels/ais-live.js index cb1dd36b0..e525763ef 100644 --- a/server/providers/vessels/ais-live.js +++ b/server/providers/vessels/ais-live.js @@ -27,6 +27,8 @@ const AISSTREAM_DEFAULT_MESSAGE_TYPES = [ 'ExtendedClassBPositionReport', 'ShipStaticData', 'StaticDataReport', + // Buoys, beacons and other aids to navigation (AIS message 21). + 'AidsToNavigationReport', ]; // Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is // REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within diff --git a/server/providers/vessels/ais-store.js b/server/providers/vessels/ais-store.js index c18a43d85..d3c38059e 100644 --- a/server/providers/vessels/ais-store.js +++ b/server/providers/vessels/ais-store.js @@ -1,4 +1,5 @@ import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js'; +import { aidToNavigationTypeName } from '../../../src/data/vesselTypes.js'; export const AISSTREAM_CACHE_MAX = 50000; export const AISSTREAM_STALE_MS = 30 * 60 * 1000; // Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m @@ -118,7 +119,12 @@ export function ingestAisStreamEnvelope(envelope) { name: vesselNameFromAis(metadata, message, staticData) || `MMSI ${mmsi}`, mmsi, imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo), - type: vesselTypeFromAis(message, staticData), + // An aid-to-navigation report's type is the kind of aid (0 to 31), not + // a ship type: it is carried as the aid's name ("Cardinal Mark N"). + type: + messageType === 'AidsToNavigationReport' + ? aidToNavigationTypeName(message.Type) + : vesselTypeFromAis(message, staticData), destination: stringValue(message.Destination ?? staticData.destination), length: staticData.length ?? null, beam: staticData.beam ?? null, diff --git a/src/data/aisStreamSentinels.test.mjs b/src/data/aisStreamSentinels.test.mjs index 93569fc97..9890a4277 100644 --- a/src/data/aisStreamSentinels.test.mjs +++ b/src/data/aisStreamSentinels.test.mjs @@ -316,3 +316,29 @@ test('a Class B report carries its dimensions in part B', () => { assert.equal(row.length, 12); assert.equal(row.beam, 4); }); + +test('an aid-to-navigation report is a row named by its kind of aid, not a ship type', () => { + assert.equal( + ingestAisStreamEnvelope({ + MessageType: 'AidsToNavigationReport', + Message: { + AidsToNavigationReport: { + UserID: 993672001, + Type: 20, + Name: 'SF APPROACH N', + }, + }, + MetaData: { + MMSI: '993672001', + latitude: 37.79, + longitude: -122.52, + time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC', + }, + }), + true, + ); + const row = rowFor('993672001'); + assert.equal(row.type, 'Cardinal Mark N'); + assert.equal(row.name, 'SF APPROACH N'); + assert.equal(row.speed, null); +}); diff --git a/src/data/fleetModels.js b/src/data/fleetModels.js index 6b436bae6..6c4081324 100644 --- a/src/data/fleetModels.js +++ b/src/data/fleetModels.js @@ -11,6 +11,9 @@ * vehicle's road or rail top), and is drawn life size: a type's published * length over the model's built length is its scale. * + * An aid to navigation (a buoy, beacon, mark, RACON or light) is drawn as + * the set's buoy of its IALA mark, by the mark its type names. + * * The resolvers are plain functions over the parsed JSON; `loadFleetModelSet` * fetches the JSON and every GLB once, up front, so no model is fetched * mid-session. @@ -18,6 +21,7 @@ import { classifyAircraft } from './aircraftClass.js'; import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js'; +import { isAidToNavigation } from './vesselTypes.js'; /** Where the model set is served, relative to the application base. */ export const FLEET_MODEL_BASE = '/models/fleet/'; @@ -84,6 +88,44 @@ const TEXT_SHIP_TYPES = [ [/pleasure|yacht/i, 37], ]; +/** + * The buoy an aid to navigation is drawn as, by the mark its type names + * ("Cardinal Mark N", "Beacon, Port hand", "Safe Water"): the first pattern + * that matches. Beacons, lights, RACONs and reference points with no mark of + * their own are the special mark. + */ +const AID_MODELS = [ + [/cardinal\b.*\bn(orth)?$/i, 'buoy_cardinal_north'], + [/cardinal\b.*\be(ast)?$/i, 'buoy_cardinal_east'], + [/cardinal\b.*\bs(outh)?$/i, 'buoy_cardinal_south'], + [/cardinal\b.*\bw(est)?$/i, 'buoy_cardinal_west'], + [/isolated danger/i, 'buoy_isolated_danger'], + [/safe water/i, 'buoy_safe_water'], + [/port hand/i, 'buoy_lateral_port'], + [/starboard hand/i, 'buoy_lateral_starboard'], + [/special mark|light vessel|lanby/i, 'buoy_special'], + [ + /^(beacon|light\b|leading light|racon|reference point|navigation aid)/i, + 'buoy_special', + ], +]; + +/** The buoy an aid to navigation whose type names no mark is drawn as. */ +export const DEFAULT_AID_MODEL = 'can_red'; + +/** + * The buoy model of an aid to navigation's type, else undefined when the + * type names no mark. + * @param {string|null} [type] + * @returns {string|undefined} + */ +export function aidToNavigationModel(type) { + const text = String(type ?? '').trim(); + for (const [pattern, model] of AID_MODELS) + if (pattern.test(text)) return model; + return undefined; +} + /** * The model for an aircraft of ICAO type `type` (A320, B738, R44), ADS-B * emitter category `category` and callsign `callsign`: the first of the @@ -397,13 +439,26 @@ export function aircraftModelSpec( /** * The model a vessel is drawn with, from its AIS ship type (a code or a text - * family), reported length and beam (metres) and name. + * family), reported length and beam (metres) and name. An aid to navigation + * ({@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. * @param {{catalog: object, types: object}} set - * @param {{type?: string|number|null, length?: number|null, - * beam?: number|null, name?: string|null}} vessel - * @returns {object} {@link fleetModelSpec} + * @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 */ -export function vesselModelSpec(set, { type, length, beam, name } = {}) { +export function vesselModelSpec(set, { type, mmsi, length, beam, name } = {}) { + if (isAidToNavigation({ type, mmsi })) { + const model = aidToNavigationModel(type); + const known = model && set.catalog[model] ? model : DEFAULT_AID_MODEL; + if (set.catalog[known]) + return Object.freeze({ + ...fleetModelSpec(set, known, null), + fixed: true, + }); + } // A beam counts only beside a reported length: a vessel drawn at its type's // typical length keeps the model's proportions. const reported = hullLength(length ?? undefined); @@ -460,6 +515,8 @@ export function namedFleetModels(types) { transit.default[0], ...Object.keys(transit.elevation), ...Object.values(CLASS_MODELS), + ...AID_MODELS.map(([, model]) => model), + DEFAULT_AID_MODEL, ]); } diff --git a/src/data/fleetModels.test.mjs b/src/data/fleetModels.test.mjs index f9ef47bc6..66ff49f3a 100644 --- a/src/data/fleetModels.test.mjs +++ b/src/data/fleetModels.test.mjs @@ -4,7 +4,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { + DEFAULT_AID_MODEL, FLEET_MODEL_BASE, + aidToNavigationModel, aircraftModelSpec, aisShipTypeCode, emitterCategory, @@ -381,6 +383,58 @@ test('vessel specs read AIS codes and text families, stretched to the reported b assert.equal(unknown.scale, 1); }); +test('aids to navigation are the buoy of the mark their type names', () => { + const cases = [ + ['Cardinal Mark N', 'buoy_cardinal_north'], + ['Beacon, Cardinal E', 'buoy_cardinal_east'], + ['Cardinal Mark S', 'buoy_cardinal_south'], + ['Beacon, Cardinal W', 'buoy_cardinal_west'], + ['Port Hand Mark', 'buoy_lateral_port'], + ['Beacon, Port hand', 'buoy_lateral_port'], + ['Starboard Hand Mark', 'buoy_lateral_starboard'], + ['Preferred Channel Starboard hand', 'buoy_lateral_starboard'], + ['Safe Water', 'buoy_safe_water'], + ['Isolated danger', 'buoy_isolated_danger'], + ['Beacon, Special Mark', 'buoy_special'], + ['Special Mark', 'buoy_special'], + ['Light Vessel / LANBY / Rigs', 'buoy_special'], + ['RACON', 'buoy_special'], + ['Light, with Sectors', 'buoy_special'], + ['Reference point', 'buoy_special'], + ['Navigation aid', 'buoy_special'], + ]; + for (const [type, model] of cases) { + assert.equal(aidToNavigationModel(type), model, type); + assert.ok(catalog[model], model); + const spec = vesselModelSpec(set, { type, mmsi: '993672001' }); + assert.equal(spec.model, model, type); + assert.equal(spec.category, 'buoys', type); + assert.equal(spec.fixed, true, type); + // A buoy is drawn as built, whatever length the report gives. + assert.equal(spec.scale, 1, type); + } + assert.equal( + vesselModelSpec(set, { type: 'Safe Water', length: 120, beam: 30 }).scale, + 1, + ); + // An aid of no stated kind, known only by its MMSI, is the default can. + assert.ok(catalog[DEFAULT_AID_MODEL]); + const unnamed = vesselModelSpec(set, { type: '', mmsi: '993660123' }); + assert.equal(unnamed.model, DEFAULT_AID_MODEL); + assert.equal(unnamed.fixed, true); + assert.equal(aidToNavigationModel('Fixed structure off shore'), undefined); + // Vessels, including those that work on buoys, keep their hulls. + for (const vessel of [ + { type: 'Buoy Tender', mmsi: '366999001', length: 60 }, + { type: '70', mmsi: '366999002' }, + { type: 'Tug', mmsi: '' }, + ]) { + const spec = vesselModelSpec(set, vessel); + assert.notEqual(spec.category, 'buoys', vessel.type); + assert.equal(spec.fixed, undefined, vessel.type); + } +}); + test('transit specs: rail category and mode, then route type, then mode, then the default', () => { assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), { model: 'intercity', diff --git a/src/data/vesselTypes.js b/src/data/vesselTypes.js index 550f4596b..addaf8572 100644 --- a/src/data/vesselTypes.js +++ b/src/data/vesselTypes.js @@ -45,3 +45,82 @@ export function normalizeVesselType(type) { if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code]; return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER'; } + +/** + * The kind of aid to navigation an AIS aid-to-navigation report (message 21) + * names by its type code, 0 to 31 (ITU-R M.1371, IALA marks): beacons and + * floating marks by their cardinal quadrant, lateral side, safe water, + * isolated danger or special mark, and the lights, RACONs and reference + * points around them. Code 0 names no kind. + */ +const AID_TYPE_NAMES = Object.freeze([ + 'Navigation aid', + 'Reference point', + 'RACON', + 'Fixed structure off shore', + 'Emergency wreck marking buoy', + 'Light, without sectors', + 'Light, with sectors', + 'Leading light front', + 'Leading light rear', + 'Beacon, Cardinal N', + 'Beacon, Cardinal E', + 'Beacon, Cardinal S', + 'Beacon, Cardinal W', + 'Beacon, Port hand', + 'Beacon, Starboard hand', + 'Beacon, Preferred channel port hand', + 'Beacon, Preferred channel starboard hand', + 'Beacon, Isolated danger', + 'Beacon, Safe water', + 'Beacon, Special mark', + 'Cardinal Mark N', + 'Cardinal Mark E', + 'Cardinal Mark S', + 'Cardinal Mark W', + 'Port hand Mark', + 'Starboard hand Mark', + 'Preferred Channel Port hand', + 'Preferred Channel Starboard hand', + 'Isolated danger', + 'Safe Water', + 'Special Mark', + 'Light Vessel / LANBY / Rigs', +]); + +/** + * The kind an AIS aid-to-navigation type code names ("19" → "Beacon, Special + * mark"); "Navigation aid" for code 0 and for a code outside 0 to 31. + * @param {string|number|null|undefined} code + * @returns {string} + */ +export function aidToNavigationTypeName(code) { + const text = String(code ?? '').trim(); + const index = /^\d+$/.test(text) ? Number(text) : -1; + return AID_TYPE_NAMES[index] ?? AID_TYPE_NAMES[0]; +} + +/** + * Words a vessel type uses for an aid to navigation. A vessel that works on + * one ("Buoy Tender", "Buoy-Laying Vessel") is a ship underway, not a mark on + * the water. + */ +const AID_TYPE_TEXT = + /navaid|\baton\b|aids? to nav|nav(igation)? aid|racon|lighthouse|lightship|light vessel|reference point|hand mark|cardinal|safe water|preferred channel|isolated danger|special mark|beacon|buoy/i; +const AID_WORKER_TEXT = /tender|laying/i; + +/** ITU MID 99: the MMSI of an AIS aid-to-navigation station (99MIDxxxx). */ +const AID_MMSI = /^99\d{7}$/; + +/** + * Whether a vessel record is an aid to navigation (a buoy, beacon, mark, + * RACON or light) rather than a vessel: its type names 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 + * @returns {boolean} + */ +export function isAidToNavigation({ type, mmsi } = {}) { + const text = String(type ?? '').trim(); + if (AID_TYPE_TEXT.test(text) && !AID_WORKER_TEXT.test(text)) return true; + return AID_MMSI.test(String(mmsi ?? '').trim()); +} diff --git a/src/data/vesselTypes.test.mjs b/src/data/vesselTypes.test.mjs new file mode 100644 index 000000000..ee8f1d511 --- /dev/null +++ b/src/data/vesselTypes.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { aidToNavigationTypeName, isAidToNavigation } from './vesselTypes.js'; + +test('an aid-to-navigation type code names its kind of aid', () => { + assert.equal(aidToNavigationTypeName(20), 'Cardinal Mark N'); + assert.equal(aidToNavigationTypeName('13'), 'Beacon, Port hand'); + assert.equal(aidToNavigationTypeName(2), 'RACON'); + assert.equal(aidToNavigationTypeName(29), 'Safe Water'); + assert.equal(aidToNavigationTypeName(31), 'Light Vessel / LANBY / Rigs'); + // Code 0, a code past 31 and no code name no kind. + for (const code of [0, 32, -1, '', null, undefined, 'x']) + assert.equal(aidToNavigationTypeName(code), 'Navigation aid', code); +}); + +test('aids to navigation are known by their type, else their MMSI', () => { + for (const type of [ + 'Beacon, Special Mark', + 'Cardinal Mark N', + 'Port Hand Mark', + 'Starboard Hand Mark', + 'Safe Water', + 'Isolated danger', + 'Preferred Channel Port hand', + 'RACON', + 'Light Vessel / LANBY / Rigs', + 'Reference point', + 'Navigation aid', + 'AtoN', + 'Lighthouse', + 'Buoy', + ]) + assert.equal(isAidToNavigation({ type, mmsi: '' }), true, type); + // AIS aid-to-navigation stations are numbered 99MIDxxxx. + assert.equal(isAidToNavigation({ type: '', mmsi: '993672001' }), true); + assert.equal(isAidToNavigation({ type: '0', mmsi: ' 992351234 ' }), true); + for (const vessel of [ + { type: 'Buoy Tender', mmsi: '366999001' }, + { type: 'Buoy-Laying Vessel', mmsi: '' }, + { type: '70', mmsi: '366999002' }, + { type: '0', mmsi: '' }, + { type: 'Cargo', mmsi: '99' }, + { type: 'Passenger', mmsi: '9936720011' }, + {}, + ]) + assert.equal(isAidToNavigation(vessel), false, JSON.stringify(vessel)); +}); diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 92c4ec493..643991e9f 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -515,3 +515,27 @@ test('AIS feed rows stay keyed by their MMSI', async () => { ['235000001', '235000001'], ); }); + +test('an aid to navigation is drawn as a disc, a vessel as its chevron', async () => { + const aid = { + ...observation('993672001'), + mmsi: '993672001', + type: 'Port Hand Mark', + speedMps: 0, + }; + const ship = { ...observation('366999002'), type: 'Cargo' }; + const { layer, primitives } = setup({ + async getSnapshot() { + return snapshot([aid, ship]); + }, + }); + await layer.update(); + const svgOf = (id) => { + const primitive = [...primitives].find((p) => p.id?.id === id); + assert.ok(primitive, id); + return atob(primitive.image.split(',')[1]); + }; + assert.match(svgOf('993672001'), / + ` + : ``; const svg = ` - + ${shape} `; const icon = 'data:image/svg+xml;base64,' + btoa(svg); @@ -265,13 +281,14 @@ export function createRendering({ let item = modelItems.get(record.id); if (!item || item.record !== record) item = { record, pickId: record, tint: null, modelOwnsVisual: false }; - const specKey = `${record.type}|${record.length}|${record.beam}|${record.name}`; + const specKey = `${record.type}|${record.mmsi}|${record.length}|${record.beam}|${record.name}`; if (item.specKey !== specKey) { item.specKey = specKey; item.spec = vesselModelSpec(set, record); } item.position = getVisual(record).waterline; - item.headingDeg = vesselCourseDeg(record); + // An aid to navigation is a fixed mark: it does not turn to a heading. + item.headingDeg = item.spec?.fixed ? 0 : vesselCourseDeg(record); item.show = record.id !== state.cockpitSubjectId; return item; } From 6c7fe48f40efbd1320d5bb344f328c21b4d5aab4 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 10:02:59 -0800 Subject: [PATCH 2/4] Show an aid to navigation's kind, not a speed or heading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A buoy's card, selected card and HUD readout showed "0.0KT · 135°" from whatever its source reported. A fixed mark has neither, so they now show its kind alone, whole ("CARDINAL MARK N", which the 14-character type cut read as "CARDINAL MARK "), in the bone white of its disc. isAidToNavigation now knows every kind an aid report names ("Light, with sectors", "Fixed structure off shore" had no aid word), leaves out a "Buoy Layer" as it does a tender, and matches each type text once: the vessel layer asks per vessel on its rotation pass, which now skips the aid discs it never turns. Aid rows no longer cost ships their place: aisStreamRows lists every vessel ahead of every aid, so the layer's 12,000-row request never drops a ship for a buoy, and vessels_in_area (and the brief built on it) counts ships only. An aid's name runs on into its NameExtension. --- changelog.d/0000-buoy-models.md | 8 ++-- docs/CURRENT-STATE.md | 2 +- server/providers/vessels/ais-store.js | 46 ++++++++++++++----- src/data/aisLiveVessels.test.mjs | 38 ++++++++++++++++ src/data/aisStreamSentinels.test.mjs | 46 +++++++++++++++++++ src/data/vesselLabels.js | 9 ++++ src/data/vesselTypes.js | 42 +++++++++++++---- src/data/vesselTypes.test.mjs | 9 ++++ 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/layers/vessels/cards.js | 65 ++++++++++++++++++--------- src/layers/vessels/rendering.js | 9 ++-- src/tools/queries/maritime.js | 10 ++++- src/tools/queries/maritime.test.mjs | 41 +++++++++++++++++ 25 files changed, 289 insertions(+), 49 deletions(-) diff --git a/changelog.d/0000-buoy-models.md b/changelog.d/0000-buoy-models.md index a41fd3957..656a93374 100644 --- a/changelog.d/0000-buoy-models.md +++ b/changelog.d/0000-buoy-models.md @@ -2,6 +2,8 @@ light or mark from the vessel feed (or any AIS station numbered 99MIDxxxx) is now the fleet set's buoy of its IALA mark (cardinal, port or starboard hand, safe water, isolated danger or special mark), standing still with no - heading, and its icon is a disc rather than a chevron. AISStream now - subscribes to aid-to-navigation reports and names each by its kind of mark - (samehkhamis, #0000). + heading, and its icon is a disc rather than a chevron. Its card and readout + 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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 74a9fd50e..0b301991b 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. Its icon is a bone-white ringed disc instead of a chevron, never rotated. 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. 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`. `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 d3c38059e..e6e8b53d7 100644 --- a/server/providers/vessels/ais-store.js +++ b/server/providers/vessels/ais-store.js @@ -1,5 +1,8 @@ import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js'; -import { aidToNavigationTypeName } from '../../../src/data/vesselTypes.js'; +import { + aidToNavigationTypeName, + isAidToNavigation, +} from '../../../src/data/vesselTypes.js'; export const AISSTREAM_CACHE_MAX = 50000; export const AISSTREAM_STALE_MS = 30 * 60 * 1000; // Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m @@ -113,18 +116,21 @@ export function ingestAisStreamEnvelope(envelope) { if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true; const staticData = _aisStreamStatic.get(mmsi) || {}; + const aidReport = messageType === 'AidsToNavigationReport'; _aisStreamVessels.set(mmsi, { lat, lon, - name: vesselNameFromAis(metadata, message, staticData) || `MMSI ${mmsi}`, + name: + (aidReport && aidNameFromAis(message)) || + vesselNameFromAis(metadata, message, staticData) || + `MMSI ${mmsi}`, mmsi, imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo), // An aid-to-navigation report's type is the kind of aid (0 to 31), not // a ship type: it is carried as the aid's name ("Cardinal Mark N"). - type: - messageType === 'AidsToNavigationReport' - ? aidToNavigationTypeName(message.Type) - : vesselTypeFromAis(message, staticData), + type: aidReport + ? aidToNavigationTypeName(message.Type) + : vesselTypeFromAis(message, staticData), destination: stringValue(message.Destination ?? staticData.destination), length: staticData.length ?? null, beam: staticData.beam ?? null, @@ -282,6 +288,15 @@ function vesselNameFromAis(metadata, message, staticData = {}) { ); } +/** + * An aid's whole name: an aid-to-navigation report carries the first 20 + * characters in `Name` and the rest, up to 14 more, in `NameExtension`. + */ +function aidNameFromAis(message) { + const name = stringValue(message.Name); + return name ? stringValue(name + stringValue(message.NameExtension)) : ''; +} + function vesselTypeFromAis(message, staticData = {}) { return stringValue( message.Type ?? @@ -291,14 +306,25 @@ function vesselTypeFromAis(message, staticData = {}) { ); } +/** + * The current rows, newest first, with every vessel ahead of every aid to + * navigation: a capped request never gives up a ship for a buoy. + */ export function aisStreamRows(maxRows) { const cutoff = Date.now() - AISSTREAM_STALE_MS; - const rows = []; + const vessels = []; + const aids = []; for (const row of _aisStreamVessels.values()) { - if (row._updatedAt >= cutoff) rows.push(row); + if (row._updatedAt >= cutoff) + (isAidToNavigation(row) ? aids : vessels).push(row); } - rows.sort((a, b) => b._updatedAt - a._updatedAt); - return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row); + const newestFirst = (a, b) => b._updatedAt - a._updatedAt; + vessels.sort(newestFirst); + if (vessels.length < maxRows) aids.sort(newestFirst); + return vessels + .concat(aids) + .slice(0, maxRows) + .map(({ _updatedAt, ...row }) => row); } function pruneAisStreamCache() { diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index e427a5412..5cfb68add 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1882,3 +1882,41 @@ test('detection objects name the vessel card their callout gives way to', () => _setVesselStateForTest({ enabled: false }); } }); + +test('an aid to navigation card shows its kind, never a speed or heading', () => { + const aid = makeRecord({ + name: 'SF APPROACH N', + mmsi: '993672001', + type: 'Cardinal Mark N', + speed: 0, + course: 135, + heading: 135, + }); + const card = buildVesselCard(aid); + assert.deepEqual(card.details, ['CARDINAL MARK N']); + assert.equal(card.accent, '217, 210, 192'); + const selected = buildSelectedVesselCard(aid); + assert.equal(selected.details[0], 'CARDINAL MARK N'); + assert.equal(selected.accent, '217, 210, 192'); + // Known only by its MMSI, an aid is named as one. + const unnamed = buildSelectedVesselCard({ ...aid, type: '' }); + assert.equal(unnamed.details[0], 'AID TO NAVIGATION'); + assert.deepEqual(buildVesselCard({ ...aid, type: '' }).details, []); +}); + +test('the HUD of a selected aid to navigation shows no speed or heading', () => { + const harness = installWireHarness(undefined, { selectedRecord: null }); + try { + Object.assign(harness.record, { + type: 'Port hand Mark', + speed: 0, + heading: 135, + course: 135, + }); + 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/); + } finally { + harness.cleanup(); + } +}); diff --git a/src/data/aisStreamSentinels.test.mjs b/src/data/aisStreamSentinels.test.mjs index 9890a4277..bb93020c4 100644 --- a/src/data/aisStreamSentinels.test.mjs +++ b/src/data/aisStreamSentinels.test.mjs @@ -342,3 +342,49 @@ test('an aid-to-navigation report is a row named by its kind of aid, not a ship assert.equal(row.name, 'SF APPROACH N'); assert.equal(row.speed, null); }); + +test("an aid's name runs on into its name extension", () => { + ingestAisStreamEnvelope({ + MessageType: 'AidsToNavigationReport', + Message: { + AidsToNavigationReport: { + UserID: 993672002, + Type: 13, + Name: 'SAN FRANCISCO MAIN S', + NameExtension: 'HIP CHANNEL 2', + }, + }, + MetaData: { + MMSI: '993672002', + ShipName: 'SAN FRANCISCO MAIN S', + latitude: 37.79, + longitude: -122.52, + time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC', + }, + }); + assert.equal(rowFor('993672002').name, 'SAN FRANCISCO MAIN SHIP CHANNEL 2'); +}); + +test('a capped row request keeps every vessel ahead of aids to navigation', () => { + // The aid reports last, so it is the newest row. + ingestAisStreamEnvelope(positionReport('244060901', { Sog: 3 })); + ingestAisStreamEnvelope({ + MessageType: 'AidsToNavigationReport', + Message: { AidsToNavigationReport: { UserID: 993672003, Type: 24 } }, + MetaData: { + MMSI: '993672003', + latitude: 37.79, + longitude: -122.52, + time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC', + }, + }); + const rows = aisStreamRows(50000); + const firstAid = rows.findIndex((row) => row.mmsi.startsWith('99')); + const lastVessel = rows.findLastIndex((row) => !row.mmsi.startsWith('99')); + assert.ok(firstAid > lastVessel, `${firstAid} > ${lastVessel}`); + const vesselCount = + rows.length - rows.filter((row) => row.mmsi.startsWith('99')).length; + assert.ok( + aisStreamRows(vesselCount).every((row) => !row.mmsi.startsWith('99')), + ); +}); diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js index 0a8a164f7..3a237f53b 100644 --- a/src/data/vesselLabels.js +++ b/src/data/vesselLabels.js @@ -46,6 +46,15 @@ const TYPE_STYLES = [ ]; const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' }; +/** + * An aid to navigation's disc and card accent: a bone white apart from every + * ship type's colour. + */ +export const AID_TO_NAVIGATION_STYLE = Object.freeze({ + css: '#d9d2c0', + accent: '217, 210, 192', +}); + // Card text for the type tokens above. The tokens stay English: TYPE_STYLES // and tests match on them. const TYPE_DISPLAY_KEYS = Object.freeze({ diff --git a/src/data/vesselTypes.js b/src/data/vesselTypes.js index addaf8572..5b9dd22b1 100644 --- a/src/data/vesselTypes.js +++ b/src/data/vesselTypes.js @@ -100,27 +100,53 @@ export function aidToNavigationTypeName(code) { return AID_TYPE_NAMES[index] ?? AID_TYPE_NAMES[0]; } +/** Every kind {@link aidToNavigationTypeName} names, lowercased. */ +const AID_TYPE_NAME_SET = new Set( + AID_TYPE_NAMES.map((name) => name.toLowerCase()), +); + /** * Words a vessel type uses for an aid to navigation. A vessel that works on - * one ("Buoy Tender", "Buoy-Laying Vessel") is a ship underway, not a mark on - * the water. + * one ("Buoy Tender", "Buoy-Laying Vessel", "Buoy Layer") is a ship underway, + * not a mark on the water. */ const AID_TYPE_TEXT = /navaid|\baton\b|aids? to nav|nav(igation)? aid|racon|lighthouse|lightship|light vessel|reference point|hand mark|cardinal|safe water|preferred channel|isolated danger|special mark|beacon|buoy/i; -const AID_WORKER_TEXT = /tender|laying/i; +const AID_WORKER_TEXT = /tender|lay(ing|er)\b/i; /** ITU MID 99: the MMSI of an AIS aid-to-navigation station (99MIDxxxx). */ const AID_MMSI = /^99\d{7}$/; +/** + * Whether each type text seen names an aid. Vessel layers ask per vessel on + * their draw passes, and a feed uses few distinct type texts, so each is + * matched once; the memo is bounded against a feed of free-form types. + */ +const AID_TYPE_MEMO = new Map(); +const AID_TYPE_MEMO_MAX = 1024; + +function typeNamesAid(text) { + let aid = AID_TYPE_MEMO.get(text); + if (aid !== undefined) return aid; + aid = + AID_TYPE_NAME_SET.has(text.toLowerCase()) || + (AID_TYPE_TEXT.test(text) && !AID_WORKER_TEXT.test(text)); + if (AID_TYPE_MEMO.size >= AID_TYPE_MEMO_MAX) AID_TYPE_MEMO.clear(); + AID_TYPE_MEMO.set(text, aid); + return aid; +} + /** * Whether a vessel record is an aid to navigation (a buoy, beacon, mark, - * RACON or light) rather than a vessel: its type names one, or its MMSI is - * an aid-to-navigation station's. A numeric AIS ship type never names one. + * 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 * @returns {boolean} */ export function isAidToNavigation({ type, mmsi } = {}) { - const text = String(type ?? '').trim(); - if (AID_TYPE_TEXT.test(text) && !AID_WORKER_TEXT.test(text)) return true; - return AID_MMSI.test(String(mmsi ?? '').trim()); + const text = typeof type === 'string' ? type.trim() : String(type ?? ''); + if (text && typeNamesAid(text)) return true; + if (mmsi === null || mmsi === undefined || mmsi === '') return false; + return AID_MMSI.test(typeof mmsi === 'string' ? mmsi.trim() : String(mmsi)); } diff --git a/src/data/vesselTypes.test.mjs b/src/data/vesselTypes.test.mjs index ee8f1d511..3a7e3ca2d 100644 --- a/src/data/vesselTypes.test.mjs +++ b/src/data/vesselTypes.test.mjs @@ -29,6 +29,11 @@ test('aids to navigation are known by their type, else their MMSI', () => { 'AtoN', 'Lighthouse', 'Buoy', + // Every kind an aid report names is an aid, whatever its MMSI. + 'Light, with sectors', + 'Leading light rear', + 'Fixed structure off shore', + 'Emergency wreck marking buoy', ]) assert.equal(isAidToNavigation({ type, mmsi: '' }), true, type); // AIS aid-to-navigation stations are numbered 99MIDxxxx. @@ -37,6 +42,10 @@ test('aids to navigation are known by their type, else their MMSI', () => { for (const vessel of [ { type: 'Buoy Tender', mmsi: '366999001' }, { type: 'Buoy-Laying Vessel', mmsi: '' }, + { type: 'Buoy Layer', mmsi: '' }, + { type: 'Lighter', mmsi: '' }, + { type: 'Flight deck', mmsi: '' }, + { type: 70, mmsi: 366999002 }, { type: '70', mmsi: '366999002' }, { type: '0', mmsi: '' }, { type: 'Cargo', mmsi: '99' }, diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 8adc01b0e..0eeb41a7c 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -776,6 +776,7 @@ "TANKER": "ناقلة", "OTHER": "أخرى", "VESSEL": "سفينة", + "AID TO NAVIGATION": "مساعدة ملاحية", "POS: LIVE": "الموضع: مباشر", "POS: {time}Z": "الموضع: {time}Z", "AIS: --": "AIS: --", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 4753051cc..bc9280d70 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -416,6 +416,7 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "SCHIFF", + "AID TO NAVIGATION": "SEEZEICHEN", "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 1c3fd8247..829ac7ea6 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -650,6 +650,7 @@ "TANKER": "PETROLERO", "OTHER": "OTRO", "VESSEL": "BUQUE", + "AID TO NAVIGATION": "AYUDA A LA NAVEGACIÓN", "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 23fd088a3..a4d3a831f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1291,6 +1291,7 @@ "AIS: {name}": "AIS : {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}", "VESSEL": "NAVIRE", + "AID TO NAVIGATION": "AIDE À LA NAVIGATION", "{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 2140be34e..f27f4279b 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -1149,6 +1149,7 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "אונייה", + "AID TO NAVIGATION": "עזר ניווט", "{speed}KT": "{speed} קשר", "{heading}DEG": "{heading}°", "POS: LIVE": "POS: חי", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 6f3fef7dc..9ee245256 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1291,6 +1291,7 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}", "VESSEL": "NAVE", + "AID TO NAVIGATION": "AIUTO ALLA NAVIGAZIONE", "{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 6aeb580ac..488f7b81e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1253,6 +1253,7 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "船舶", + "AID TO NAVIGATION": "航路標識", "{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 3cfde1eea..5944dbc6b 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1289,6 +1289,7 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "EMBARCAÇÃO", + "AID TO NAVIGATION": "AUXÍLIO À NAVEGAÇÃO", "{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 b7f2b42d7..5065907c4 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -394,6 +394,7 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}", "VESSEL": "СУДНО", + "AID TO NAVIGATION": "СРЕДСТВО НАВИГАЦИИ", "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 acfd2916d..2eebc0df0 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -382,6 +382,7 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "FARTYG", + "AID TO NAVIGATION": "SJÖMÄRKE", "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 81fb172f5..8face8fd5 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1309,6 +1309,7 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}", "VESSEL": "СУДНО", + "AID TO NAVIGATION": "ЗАСІБ НАВІГАЦІЇ", "{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 715c25ace..d8bcb9d63 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1229,6 +1229,7 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "船舶", + "AID TO NAVIGATION": "助航标志", "{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 bd2fa477f..cf782e389 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1224,6 +1224,7 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "船舶", + "AID TO NAVIGATION": "助航標誌", "{speed}KT": "{speed} kn", "{heading}DEG": "{heading}°", "POS: LIVE": "POS:實時", diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index 49c0264e2..f8371c372 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -1,8 +1,10 @@ import { + AID_TO_NAVIGATION_STYLE, accentForVesselType, displayVesselType, normalizeVesselType, } from '../../data/vesselLabels.js'; +import { isAidToNavigation } from '../../data/vesselTypes.js'; import { formatNumber, t } from '../../i18n/index.js'; import { roundHeadingDegrees } from '../../data/headingDegrees.js'; @@ -16,14 +18,18 @@ export function createCards({ function updateSelectedVesselHud(record) { // Pinned vessels missing from recent refreshes get a stale marker const stale = (record.missedRefreshes || 0) > 0; + // An aid to navigation is a fixed mark: it has no speed or heading to show. + const aid = isAidToNavigation(record); services.readouts?.presentSelectedVessel({ text: [ t('AIS: {name}', { name: trimHudValue(record.name, 32) }), - t('{type} SPD: {speed} HDG: {heading}', { - type: trimHudValue(record.type || t('VESSEL'), 24), - speed: formatSpeed(record.speed), - heading: formatHeading(record.heading ?? record.course), - }), + aid + ? trimHudValue(record.type || t('AID TO NAVIGATION'), 24) + : t('{type} SPD: {speed} HDG: {heading}', { + type: trimHudValue(record.type || t('VESSEL'), 24), + speed: formatSpeed(record.speed), + heading: formatHeading(record.heading ?? record.course), + }), `MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`, ].join('\n'), }); @@ -43,20 +49,21 @@ export function createCards({ /** * Card model for an ambient (decluttered-in) vessel — name title plus one * compact type/speed/heading detail line, anchored at the record's current - * rendered position (height-datum caveat: no datum work here). Pure — - * exported for unit tests. + * rendered position (height-datum caveat: no datum work here). An aid to + * navigation's line is its kind alone. Pure — exported for unit tests. * @param {Object} record - Vessel record. * @returns {Object} vesselLabels entry. */ function buildVesselCard(record) { const parts = []; + const aid = isAidToNavigation(record); const type = vesselTypeShort(record); if (type) parts.push(type); - if (record.speed !== null && record.speed !== undefined) + if (!aid && record.speed !== null && record.speed !== undefined) parts.push(formatSpeed(record.speed)); const direction = record.heading ?? record.course; - if (Number.isFinite(direction)) + if (!aid && Number.isFinite(direction)) parts.push(`${roundHeadingDegrees(direction)}°`); return { id: vesselOverlayEntryId(record), @@ -65,7 +72,7 @@ export function createCards({ components.rendering.getVisual(record).billboard?.position || components.rendering.getVisual(record).position, gapPx: 10, - accent: accentForVesselType(record.type), + accent: vesselAccent(record, aid), title: trimHudValue(displayVesselName(record), 26), details: parts.length ? [parts.join(' · ')] : [], selected: false, @@ -76,22 +83,26 @@ export function createCards({ /** * Card model for the click-selected vessel — the full-detail card, drawn last * (on top) and never distance-faded by the overlay. Pinned-but-vanished - * vessels carry a STALE marker (mirrors the HUD readout). Pure — exported - * for unit tests. + * vessels carry a STALE marker (mirrors the HUD readout). An aid to + * navigation shows its kind with no speed or heading. Pure — exported for + * unit tests. * @param {Object} record - Selected vessel record. * @returns {Object} vesselLabels entry. */ function buildSelectedVesselCard(record) { + const aid = isAidToNavigation(record); const direction = record.heading ?? record.course; const details = [ - [ - vesselTypeShort(record) || t('VESSEL'), - formatSpeed(record.speed), - Number.isFinite(direction) - ? `${roundHeadingDegrees(direction)}°` - : '--°', - ].join(' · '), + aid + ? vesselTypeShort(record) || t('AID TO NAVIGATION') + : [ + vesselTypeShort(record) || t('VESSEL'), + formatSpeed(record.speed), + Number.isFinite(direction) + ? `${roundHeadingDegrees(direction)}°` + : '--°', + ].join(' · '), ]; const destination = String(record.destination || '').trim(); if (destination) details.push(`→ ${trimHudValue(destination, 24)}`); @@ -106,7 +117,7 @@ export function createCards({ components.rendering.getVisual(record).billboard?.position || components.rendering.getVisual(record).position, gapPx: 12, - accent: accentForVesselType(record.type), + accent: vesselAccent(record, aid), title: trimHudValue(displayVesselName(record), 32), details, selected: true, @@ -114,6 +125,14 @@ export function createCards({ }; } + /** A card's accent: an aid's disc colour, else its ship type's. */ + + function vesselAccent(record, aid) { + return aid + ? AID_TO_NAVIGATION_STYLE.accent + : accentForVesselType(record.type); + } + /** Stable overlay identity for keyed and source-retained unkeyed rows. */ function vesselOverlayEntryId(record) { @@ -129,9 +148,15 @@ export function createCards({ * Uppercased AIS type for the card (empty string when unknown). Free feed * text is bounded to the card width; a translated type token is a short * catalog word and shows whole, never cut mid-word ("AUXILIAR DE PUERTO"). + * An aid's kind is the whole line, so it keeps the room speed and heading + * would take ("CARDINAL MARK N", not "CARDINAL MARK "). */ function vesselTypeShort(record) { + if (isAidToNavigation(record)) { + const kind = String(record.type || '').trim(); + return kind ? trimHudValue(kind.toUpperCase(), 26) : ''; + } const feedType = normalizeVesselType(record.type); const shown = displayVesselType(record.type); return shown === feedType diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 66e4b7cd8..b2c45bedf 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -1,6 +1,7 @@ import * as Cesium from 'cesium'; import { t } from '../../i18n/index.js'; import { + AID_TO_NAVIGATION_STYLE, vesselTypeCss, vesselOverlayCohortLimit, VESSEL_OVERLAY_SOURCE_ID, @@ -30,9 +31,6 @@ import { import { createFleetModelLayer } from '../../data/fleetModelInstances.js'; import { isAidToNavigation } from '../../data/vesselTypes.js'; -/** An aid to navigation's disc: a bone white apart from every ship type's colour. */ -const AID_ICON_CSS = '#d9d2c0'; - /** How often vessels move on as the world clock plays (wall ms). */ const FOLLOW_POSITION_MS = 250; @@ -213,7 +211,7 @@ export function createRendering({ const cssColor = selected ? '#ffffff' : aid - ? AID_ICON_CSS + ? AID_TO_NAVIGATION_STYLE.css : vesselTypeCss(record.type); const key = `${cssColor}:${selected ? 'selected' : 'normal'}:${aid ? 'aid' : 'ship'}`; if (vesselState.shipIconCache.has(key)) @@ -482,7 +480,8 @@ export function createRendering({ visible && record.id !== state.cockpitSubjectId && !visual.modelOwnsVisual; - if (visible && doRotations && scene) { + // An aid's disc is never turned (vesselCourseDeg). + if (visible && doRotations && scene && !isAidToNavigation(record)) { const rot = screenProjectedRotation( scene, visual.position, diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index aeb90714f..439721c77 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -21,7 +21,10 @@ import { thinEvenly, } from '../results.js'; import { roundHeadingDegrees } from '../../data/headingDegrees.js'; -import { normalizeVesselType } from '../../data/vesselTypes.js'; +import { + isAidToNavigation, + normalizeVesselType, +} from '../../data/vesselTypes.js'; const MMSI_SCHEMA = Object.freeze({ type: 'string', @@ -116,7 +119,8 @@ export const vesselsInArea = defineTool({ title: 'Vessels in an area', description: 'Ships currently reported by AIS inside an area, nearest the center ' + - 'first, optionally filtered by vessel type. group_by counts every matched ' + + 'first, optionally filtered by vessel type. Buoys, beacons and other ' + + 'aids to navigation are not ships and are left out. group_by counts every matched ' + 'ship by type or destination, and stats summarizes their speed; both ' + 'cover all matches, not just the rows returned.', inputSchema: { @@ -152,6 +156,8 @@ export const vesselsInArea = defineTool({ const rows = snapshot.records .filter( (record) => + // A buoy or beacon in the feed is not a ship to count. + !isAidToNavigation(record) && areaContains(area, { lat: record.latitude, lon: record.longitude }) && (!wanted || (vesselType(record.type) ?? '').toLowerCase().includes(wanted)), diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index fa835b5b7..5715a1e4b 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -467,3 +467,44 @@ test('vessels in an area can be counted by destination with a speed summary', as speed_mps: { count: 2, min: 6.2, median: 6.2, mean: 6.2, max: 6.2 }, }); }); + +test('vessels in an area leave out buoys and beacons', async () => { + const withAids = { + ...vessels(), + async getSnapshot() { + return { + records: [ + ...records, + vessel('993672001', 37.8, -122.42, { + name: 'SF APPROACH N', + type: 'Cardinal Mark N', + speedMps: 0, + headingDeg: 135, + }), + vessel('993672002', 37.79, -122.43, { type: '' }), + ], + source: 'AISStream', + observedAtMs: Date.UTC(2026, 0, 1), + freshness: 'current', + }; + }, + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { vessels: withAids }, + }); + const all = await catalog.call('vessels_in_area', { + area: bay, + group_by: 'type', + }); + assert.equal(all.data.total, 2); + assert.equal(all.summary.split(' in ')[0], '2 vessels'); + assert.deepEqual(all.data.rows.map((row) => row.mmsi).sort(), [ + '366999712', + '538001234', + ]); + assert.equal(all.data.breakdown.unknown_count, 0); + assert.ok( + !all.data.breakdown.groups.some((group) => /mark/i.test(group.value)), + ); +}); From 9ae121c7c05d63b7742ed0b0af0524bccd8c1d5a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 11:02:14 -0800 Subject: [PATCH 3/4] Number the buoy models changelog fragment for #1733 --- changelog.d/{0000-buoy-models.md => 1733-buoy-models.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0000-buoy-models.md => 1733-buoy-models.md} (100%) diff --git a/changelog.d/0000-buoy-models.md b/changelog.d/1733-buoy-models.md similarity index 100% rename from changelog.d/0000-buoy-models.md rename to changelog.d/1733-buoy-models.md From 52507a075569cb5b7868b80c443e972f3a2fd39c Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 14 Nov 2026 11:07:13 -0800 Subject: [PATCH 4/4] 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]), /