mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
Merge pull request #1733 from bilawalsidhu/fleet/aid-to-navigation-buoys
Draw aids to navigation as buoys
This commit is contained in:
38 files changed
+762
-49
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
+69
-5
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -776,6 +776,8 @@
|
||||
"TANKER": "ناقلة",
|
||||
"OTHER": "أخرى",
|
||||
"VESSEL": "سفينة",
|
||||
"AID TO NAVIGATION": "مساعدة ملاحية",
|
||||
"VIRTUAL": "افتراضي",
|
||||
"POS: LIVE": "الموضع: مباشر",
|
||||
"POS: {time}Z": "الموضع: {time}Z",
|
||||
"AIS: --": "AIS: --",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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: --",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: חי",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: ライブ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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 …",
|
||||
|
||||
@@ -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": "ПОЗ.: ОНЛАЙН",
|
||||
|
||||
@@ -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:实时",
|
||||
|
||||
@@ -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:實時",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+55
-20
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'), /<circle/);
|
||||
// The source's virtual flag reaches the layer's record.
|
||||
const recordOf = (id) => [...primitives].find((p) => p.id?.id === id).id;
|
||||
assert.equal(recordOf('993672001').virtual, false);
|
||||
assert.equal(recordOf('366999002').virtual, false);
|
||||
assert.doesNotMatch(svgOf('993672001'), /<path/);
|
||||
assert.match(svgOf('366999002'), /<path/);
|
||||
});
|
||||
|
||||
test('a virtual aid to navigation keeps its disc and its virtual flag', async () => {
|
||||
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]), /<circle/);
|
||||
});
|
||||
@@ -31,6 +31,8 @@ export function normalizeVessel(row) {
|
||||
speed: finiteNumber(row.speed),
|
||||
course: finiteNumber(row.course),
|
||||
heading: finiteNumber(row.heading),
|
||||
// A virtual aid to navigation: a mark with nothing physical there.
|
||||
virtual: row.virtual === true,
|
||||
lastPositionUtc: String(row.last_position_UTC || ''),
|
||||
lastPositionEpoch: finiteNumber(row.last_position_epoch),
|
||||
missedRefreshes: 0,
|
||||
@@ -160,6 +162,7 @@ export class VesselRecords {
|
||||
record.speed = next.speed;
|
||||
record.course = next.course;
|
||||
record.heading = next.heading;
|
||||
record.virtual = next.virtual;
|
||||
record.lastPositionUtc = next.lastPositionUtc;
|
||||
record.lastPositionEpoch = next.lastPositionEpoch;
|
||||
record.missedRefreshes = 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
AID_TO_NAVIGATION_STYLE,
|
||||
vesselTypeCss,
|
||||
vesselOverlayCohortLimit,
|
||||
VESSEL_OVERLAY_SOURCE_ID,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
vesselModelSpec,
|
||||
} from '../../data/fleetModels.js';
|
||||
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
|
||||
import { isAidToNavigation } from '../../data/vesselTypes.js';
|
||||
|
||||
/** How often vessels move on as the world clock plays (wall ms). */
|
||||
const FOLLOW_POSITION_MS = 250;
|
||||
@@ -182,12 +184,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 +199,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 +207,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_TO_NAVIGATION_STYLE.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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user