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/1733-buoy-models.md b/changelog.d/1733-buoy-models.md new file mode 100644 index 000000000..a2de9e612 --- /dev/null +++ b/changelog.d/1733-buoy-models.md @@ -0,0 +1,11 @@ +- 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. 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. 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 59077f153..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. 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/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..6c4d8a57a 100644 --- a/server/providers/vessels/ais-store.js +++ b/server/providers/vessels/ais-store.js @@ -1,4 +1,8 @@ import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.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 @@ -112,13 +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), - 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: aidReport + ? aidToNavigationTypeName(message.Type) + : vesselTypeFromAis(message, staticData), destination: stringValue(message.Destination ?? staticData.destination), length: staticData.length ?? null, beam: staticData.beam ?? null, @@ -131,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(), }); @@ -276,6 +291,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 ?? @@ -285,14 +309,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..6608eb055 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1882,3 +1882,57 @@ 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/); + 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 93569fc97..563bf9f65 100644 --- a/src/data/aisStreamSentinels.test.mjs +++ b/src/data/aisStreamSentinels.test.mjs @@ -316,3 +316,97 @@ 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); +}); + +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')), + ); +}); + +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 6b436bae6..69ab2a678 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,33 @@ 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. 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, length?: number|null, - * beam?: number|null, name?: string|null}} vessel - * @returns {object} {@link fleetModelSpec} + * @param {{type?: string|number|null, mmsi?: string|number|null, + * 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, 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; + 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 +522,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..d48e744c1 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,67 @@ 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); + // 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 }, + { 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/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 550f4596b..742187548 100644 --- a/src/data/vesselTypes.js +++ b/src/data/vesselTypes.js @@ -45,3 +45,110 @@ 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]; +} + +/** 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", "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|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 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, 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, 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; + 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 new file mode 100644 index 000000000..65816743c --- /dev/null +++ b/src/data/vesselTypes.test.mjs @@ -0,0 +1,61 @@ +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', + // 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); + // 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); + 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' }, + { type: 'Passenger', mmsi: '9936720011' }, + {}, + ]) + assert.equal(isAidToNavigation(vessel), false, JSON.stringify(vessel)); +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 8adc01b0e..44024e10f 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -776,6 +776,8 @@ "TANKER": "ناقلة", "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 4753051cc..cdb669452 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -416,6 +416,8 @@ "AIS: {name}": "AIS: {name}", "{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 1c3fd8247..b0d10ae06 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -650,6 +650,8 @@ "TANKER": "PETROLERO", "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 23fd088a3..ffe522ad9 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1291,6 +1291,8 @@ "AIS: {name}": "AIS : {name}", "{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 2140be34e..cfe612894 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -1149,6 +1149,8 @@ "AIS: {name}": "AIS: {name}", "{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 6f3fef7dc..e8d939278 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1291,6 +1291,8 @@ "AIS: {name}": "AIS: {name}", "{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 6aeb580ac..8039831d8 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1253,6 +1253,8 @@ "AIS: {name}": "AIS: {name}", "{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 3cfde1eea..18b6d1dd2 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1289,6 +1289,8 @@ "AIS: {name}": "AIS: {name}", "{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 b7f2b42d7..b58d78d26 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -394,6 +394,8 @@ "AIS: {name}": "AIS: {name}", "{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 acfd2916d..bd1f25a7e 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -382,6 +382,8 @@ "AIS: {name}": "AIS: {name}", "{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 81fb172f5..9506d0a31 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1309,6 +1309,8 @@ "AIS: {name}": "AIS: {name}", "{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 715c25ace..f34c36028 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1229,6 +1229,8 @@ "AIS: {name}": "AIS: {name}", "{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 bd2fa477f..0e01c7773 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1224,6 +1224,8 @@ "AIS: {name}": "AIS: {name}", "{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 49c0264e2..f215c6b09 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,21 @@ 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 + ? 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), + heading: formatHeading(record.heading ?? record.course), + }), `MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`, ].join('\n'), }); @@ -43,20 +52,22 @@ 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 (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; - if (Number.isFinite(direction)) + if (!aid && Number.isFinite(direction)) parts.push(`${roundHeadingDegrees(direction)}°`); return { id: vesselOverlayEntryId(record), @@ -65,7 +76,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 +87,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 + ? aidKind(vesselTypeShort(record) || t('AID TO NAVIGATION'), record) + : [ + 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 +121,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 +129,20 @@ 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) { + 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 +158,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/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 92c4ec493..cffd22dbd 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -515,3 +515,50 @@ 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'), / [...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]), / + ` + : ``; const svg = ` - + ${shape} `; const icon = 'data:image/svg+xml;base64,' + btoa(svg); @@ -260,18 +274,24 @@ export function createRendering({ ); } - /** The model item for `record`, its spec rebuilt only when its type or size changes. */ + /** + * The model item for `record`, its spec rebuilt only when its type or size + * changes; null for a virtual aid to navigation, which keeps its disc: no + * buoy stands there (vesselModelSpec). + */ function modelItem(record, set) { + if (record.virtual === true) return null; 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; } @@ -294,7 +314,8 @@ export function createRendering({ limitSq ) continue; - into.set(record.id, modelItem(record, set)); + const item = modelItem(record, set); + if (item) into.set(record.id, item); } } @@ -330,8 +351,10 @@ export function createRendering({ gatherModelItems(set, next); } else { for (const [id, item] of modelItems) - if (state.records.byId.get(id) === item.record) - next.set(id, modelItem(item.record, set)); + if (state.records.byId.get(id) === item.record) { + const kept = modelItem(item.record, set); + if (kept) next.set(id, kept); + } } for (const [id, item] of modelItems) if (next.get(id) !== item) handBackVisual(item.record, occluder); @@ -465,7 +488,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/sources/live/contract.js b/src/sources/live/contract.js index c40d23b0f..e201fc8de 100644 --- a/src/sources/live/contract.js +++ b/src/sources/live/contract.js @@ -12,7 +12,9 @@ * snapshots. A vessel record's `id` is whatever its source gives, such as a * provider id; its `mmsi` is an optional attribute ('' or absent when * unknown), never its key, since vessels can share an MMSI. A source that - * only has MMSIs uses the MMSI as the id. + * only has MMSIs uses the MMSI as the id. A vessel record's `virtual` is true + * for a virtual aid to navigation, a mark broadcast where nothing physical + * stands (AIS message 21's virtual flag), else false; any source may set it. * * A source may also declare a timeline (see readSnapshotTimeline): it can * answer a snapshot at a given time, so the layer follows the world clock. diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index c789458ec..2c22012bf 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -221,6 +221,18 @@ test('vessel identity, opaque reference and heading/course remain distinct, unkn assert.equal(row.altitudeDatum, 'sea-surface'); assert.equal(row.observedAtMs, now); assert.equal('extra' in row, false); + // Only a source's own true marks a virtual aid to navigation. + assert.equal(row.virtual, false); + const base = { mmsi: '993672001', lat: 30, lon: -97 }; + assert.equal( + normalizeVesselObservation({ ...base, virtual: true }).virtual, + true, + ); + for (const virtual of ['true', 1, false, null]) + assert.equal( + normalizeVesselObservation({ ...base, virtual }).virtual, + false, + ); assert.equal( normalizeVesselObservation({ mmsi: 'x', lat: null, lon: null }), null, diff --git a/src/sources/live/vessels.js b/src/sources/live/vessels.js index 3ea9c113f..d75291911 100644 --- a/src/sources/live/vessels.js +++ b/src/sources/live/vessels.js @@ -55,6 +55,8 @@ export function normalizeVesselObservation(row, reference = null, id = null) { epoch(row.last_position_epoch, 1000) ?? epoch(Date.parse(row.last_position_UTC)), altitudeDatum: 'sea-surface', + // A virtual aid to navigation: a mark broadcast with nothing there. + virtual: row.virtual === true, }; } 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)), + ); +});