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 = ``;
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;
}