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.
This commit is contained in:
Sameh Khamis committed 2026-11-14 09:54:01 -08:00
1 parent f24abfc88f
commit 279aedb2f4
14 files changed
+345 -14

No files matched your search

+1 -1
View File
@@ -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
+7
View File
@@ -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).
+1 -1
View File
@@ -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.
+6
View File
@@ -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
+6
View File
@@ -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",
+2
View File
@@ -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
+7 -1
View File
@@ -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,
+26
View File
@@ -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);
});
+62 -5
View File
@@ -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,
]);
}
+54
View File
@@ -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',
+79
View File
@@ -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());
}
+47
View File
@@ -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));
});
+24
View File
@@ -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'), /<circle/);
assert.doesNotMatch(svgOf('993672001'), /<path/);
assert.match(svgOf('366999002'), /<path/);
});
+23 -6
View File
@@ -28,6 +28,10 @@ import {
vesselModelSpec,
} from '../../data/fleetModels.js';
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;
@@ -182,12 +186,14 @@ export function createRendering({
* Best available real-world direction of travel for a vessel, degrees
* clockwise from north (true heading preferred, course-over-ground fallback).
* Screen rotation is computed from this by the shared projected-rotation
* pass in updateVisibility — never directly from the compass value.
* pass in updateVisibility — never directly from the compass value. An aid
* to navigation is fixed, so it has none.
* @param {Object} record - Vessel record.
* @returns {number} Course in degrees (0 when unknown).
*/
function vesselCourseDeg(record) {
if (isAidToNavigation(record)) return 0;
const direction = record.heading ?? record.course;
return Number.isFinite(direction) ? direction : 0;
}
@@ -195,6 +201,7 @@ export function createRendering({
/**
* Build (and cache) a chevron/delta-wing SVG data URL tinted for the vessel.
* The shape points north (up) so billboard rotation maps directly to heading.
* An aid to navigation, which has no heading, is a ringed disc instead.
* One icon is generated per color+variant and reused across all billboards.
* @param {Object} record - Vessel record (drives per-type tint).
* @param {boolean} selected - True for the white/brighter selected variant.
@@ -202,16 +209,25 @@ export function createRendering({
*/
function shipIcon(record, selected) {
const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type);
const key = `${cssColor}:${selected ? 'selected' : 'normal'}`;
const aid = isAidToNavigation(record);
const cssColor = selected
? '#ffffff'
: aid
? AID_ICON_CSS
: vesselTypeCss(record.type);
const key = `${cssColor}:${selected ? 'selected' : 'normal'}:${aid ? 'aid' : 'ship'}`;
if (vesselState.shipIconCache.has(key))
return vesselState.shipIconCache.get(key);
const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
const strokeWidth = selected ? 1.1 : 0.7;
const shape = aid
? `<circle r="8" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth * 2}"/>
<circle r="3" fill="${stroke}"/>`
: `<path d="M0,-14 L11,10 L4,7 L0,14 L-4,7 L-11,10 Z" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linejoin="round"/>`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<g transform="translate(16,16)">
<path d="M0,-14 L11,10 L4,7 L0,14 L-4,7 L-11,10 Z" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linejoin="round"/>
${shape}
</g>
</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;
}