mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Show an aid to navigation's kind, not a speed or heading
A buoy's card, selected card and HUD readout showed "0.0KT · 135°" from
whatever its source reported. A fixed mark has neither, so they now show
its kind alone, whole ("CARDINAL MARK N", which the 14-character type cut
read as "CARDINAL MARK "), in the bone white of its disc.
isAidToNavigation now knows every kind an aid report names ("Light, with
sectors", "Fixed structure off shore" had no aid word), leaves out a
"Buoy Layer" as it does a tender, and matches each type text once: the
vessel layer asks per vessel on its rotation pass, which now skips the
aid discs it never turns.
Aid rows no longer cost ships their place: aisStreamRows lists every
vessel ahead of every aid, so the layer's 12,000-row request never drops
a ship for a buoy, and vessels_in_area (and the brief built on it)
counts ships only. An aid's name runs on into its NameExtension.
This commit is contained in:
1 parent
279aedb2f4
commit
6c7fe48f40
25 files changed
+289
-49
No files matched your search
@@ -2,6 +2,8 @@
|
|||||||
light or mark from the vessel feed (or any AIS station numbered 99MIDxxxx)
|
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
|
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
|
hand, safe water, isolated danger or special mark), standing still with no
|
||||||
heading, and its icon is a disc rather than a chevron. AISStream now
|
heading, and its icon is a disc rather than a chevron. Its card and readout
|
||||||
subscribes to aid-to-navigation reports and names each by its kind of mark
|
show its kind of mark with no speed or heading. AISStream now subscribes to
|
||||||
(samehkhamis, #0000).
|
aid-to-navigation reports and names each by its kind of mark and its full
|
||||||
|
name; the feed lists every ship ahead of any aid, and `vessels_in_area`
|
||||||
|
counts ships only (samehkhamis, #0000).
|
||||||
@@ -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
|
30-second backoff ahead of the current clock are ignored. A smaller rollback
|
||||||
can retain a bounded retry delay of up to 30 seconds.
|
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.
|
- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Every aircraft model draws its mesh's own colours (`aircraftModelBlend`, `src/data/aircraftModelLighting.js`): Cesium's `ColorBlendMode.HIGHLIGHT` with white, in Flights, Military Flights and Local ADS-B alike; the tracked colour (cyan, or amber in Military Flights) and the emergency red multiply into the mesh, so its paint still shows. IR boost draws them flat white (`MIX` at `1.0`). Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit.
|
||||||
- Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. An aid to navigation is not a hull: a vessel record whose type names a buoy, beacon, mark, RACON or light, or whose MMSI is an aid-to-navigation station's (`99MIDxxxx`), is `isAidToNavigation` (`src/data/vesselTypes.js`) and is drawn as the set's `buoys` model of the IALA mark its type names (`aidToNavigationModel`, `src/data/fleetModels.js`: cardinal N/E/S/W, port or starboard hand, preferred channel by its side, safe water, isolated danger, special mark; a beacon, light, light vessel, RACON or reference point with no mark of its own is the special mark), else a red can (`DEFAULT_AID_MODEL`), at its built size and facing north whatever heading it reports. Its icon is a bone-white ringed disc instead of a chevron, never rotated. AISStream subscribes to `AidsToNavigationReport` (message 21) by default, and the server names each report's aid type code (0 to 31) as its kind (`aidToNavigationTypeName`: `20` is `Cardinal Mark N`) rather than reading it as a ship type. A transit vehicle's model follows its layer mode (bus, tram, subway, rail or ferry; a vehicle of `unknown` mode keeps its icon), a train's its rail category and mode (rail, metro or tram). Neither the transit nor the rail sources carry a GTFS `route_type` or a finer mode (monorail, gondola, funicular, cable car), so the set's models for those and their `transit.elevation` lifts are not reached by any live source today. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay.
|
- Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. An aid to navigation is not a hull: a vessel record whose type names a buoy, beacon, mark, RACON or light, or whose MMSI is an aid-to-navigation station's (`99MIDxxxx`), is `isAidToNavigation` (`src/data/vesselTypes.js`) and is drawn as the set's `buoys` model of the IALA mark its type names (`aidToNavigationModel`, `src/data/fleetModels.js`: cardinal N/E/S/W, port or starboard hand, preferred channel by its side, safe water, isolated danger, special mark; a beacon, light, light vessel, RACON or reference point with no mark of its own is the special mark), else a red can (`DEFAULT_AID_MODEL`), at its built size and facing north whatever heading it reports. A type is an aid's when it is one of the 32 kinds an aid report names or uses a word for one (buoy, beacon, cardinal, safe water, RACON, lighthouse and so on); a vessel that works on aids (`Buoy Tender`, `Buoy Layer`) is not one. Its icon is a bone-white ringed disc (`AID_TO_NAVIGATION_STYLE`, `src/data/vesselLabels.js`) instead of a chevron, never rotated, and its card, selected card and HUD readout (`src/layers/vessels/cards.js`) show its kind alone, whole, in that colour: no speed or heading, since a fixed mark has neither. AISStream subscribes to `AidsToNavigationReport` (message 21) by default, and the server names each report's aid type code (0 to 31) as its kind (`aidToNavigationTypeName`: `20` is `Cardinal Mark N`) rather than reading it as a ship type, and its name runs on into the report's `NameExtension`. `aisStreamRows` (`server/providers/vessels/ais-store.js`) puts every vessel ahead of every aid, so a capped request (the layer asks for 12,000 rows) never gives up a ship for a buoy. The `vessels_in_area` tool (and the brief built on it) counts ships only: it leaves aids out of its rows, counts, breakdowns and speed stats. A transit vehicle's model follows its layer mode (bus, tram, subway, rail or ferry; a vehicle of `unknown` mode keeps its icon), a train's its rail category and mode (rail, metro or tram). Neither the transit nor the rail sources carry a GTFS `route_type` or a finer mode (monorail, gondola, funicular, cable car), so the set's models for those and their `transit.elevation` lifts are not reached by any live source today. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay.
|
||||||
- **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). The models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload.
|
- **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 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.
|
- **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.
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js';
|
import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js';
|
||||||
import { aidToNavigationTypeName } from '../../../src/data/vesselTypes.js';
|
import {
|
||||||
|
aidToNavigationTypeName,
|
||||||
|
isAidToNavigation,
|
||||||
|
} from '../../../src/data/vesselTypes.js';
|
||||||
export const AISSTREAM_CACHE_MAX = 50000;
|
export const AISSTREAM_CACHE_MAX = 50000;
|
||||||
export const AISSTREAM_STALE_MS = 30 * 60 * 1000;
|
export const AISSTREAM_STALE_MS = 30 * 60 * 1000;
|
||||||
// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m
|
// Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m
|
||||||
@@ -113,18 +116,21 @@ export function ingestAisStreamEnvelope(envelope) {
|
|||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true;
|
||||||
|
|
||||||
const staticData = _aisStreamStatic.get(mmsi) || {};
|
const staticData = _aisStreamStatic.get(mmsi) || {};
|
||||||
|
const aidReport = messageType === 'AidsToNavigationReport';
|
||||||
_aisStreamVessels.set(mmsi, {
|
_aisStreamVessels.set(mmsi, {
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
name: vesselNameFromAis(metadata, message, staticData) || `MMSI ${mmsi}`,
|
name:
|
||||||
|
(aidReport && aidNameFromAis(message)) ||
|
||||||
|
vesselNameFromAis(metadata, message, staticData) ||
|
||||||
|
`MMSI ${mmsi}`,
|
||||||
mmsi,
|
mmsi,
|
||||||
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
|
imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo),
|
||||||
// An aid-to-navigation report's type is the kind of aid (0 to 31), not
|
// 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").
|
// a ship type: it is carried as the aid's name ("Cardinal Mark N").
|
||||||
type:
|
type: aidReport
|
||||||
messageType === 'AidsToNavigationReport'
|
? aidToNavigationTypeName(message.Type)
|
||||||
? aidToNavigationTypeName(message.Type)
|
: vesselTypeFromAis(message, staticData),
|
||||||
: vesselTypeFromAis(message, staticData),
|
|
||||||
destination: stringValue(message.Destination ?? staticData.destination),
|
destination: stringValue(message.Destination ?? staticData.destination),
|
||||||
length: staticData.length ?? null,
|
length: staticData.length ?? null,
|
||||||
beam: staticData.beam ?? null,
|
beam: staticData.beam ?? null,
|
||||||
@@ -282,6 +288,15 @@ function vesselNameFromAis(metadata, message, staticData = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An aid's whole name: an aid-to-navigation report carries the first 20
|
||||||
|
* characters in `Name` and the rest, up to 14 more, in `NameExtension`.
|
||||||
|
*/
|
||||||
|
function aidNameFromAis(message) {
|
||||||
|
const name = stringValue(message.Name);
|
||||||
|
return name ? stringValue(name + stringValue(message.NameExtension)) : '';
|
||||||
|
}
|
||||||
|
|
||||||
function vesselTypeFromAis(message, staticData = {}) {
|
function vesselTypeFromAis(message, staticData = {}) {
|
||||||
return stringValue(
|
return stringValue(
|
||||||
message.Type ??
|
message.Type ??
|
||||||
@@ -291,14 +306,25 @@ function vesselTypeFromAis(message, staticData = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current rows, newest first, with every vessel ahead of every aid to
|
||||||
|
* navigation: a capped request never gives up a ship for a buoy.
|
||||||
|
*/
|
||||||
export function aisStreamRows(maxRows) {
|
export function aisStreamRows(maxRows) {
|
||||||
const cutoff = Date.now() - AISSTREAM_STALE_MS;
|
const cutoff = Date.now() - AISSTREAM_STALE_MS;
|
||||||
const rows = [];
|
const vessels = [];
|
||||||
|
const aids = [];
|
||||||
for (const row of _aisStreamVessels.values()) {
|
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);
|
const newestFirst = (a, b) => b._updatedAt - a._updatedAt;
|
||||||
return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row);
|
vessels.sort(newestFirst);
|
||||||
|
if (vessels.length < maxRows) aids.sort(newestFirst);
|
||||||
|
return vessels
|
||||||
|
.concat(aids)
|
||||||
|
.slice(0, maxRows)
|
||||||
|
.map(({ _updatedAt, ...row }) => row);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneAisStreamCache() {
|
function pruneAisStreamCache() {
|
||||||
|
|||||||
@@ -1882,3 +1882,41 @@ test('detection objects name the vessel card their callout gives way to', () =>
|
|||||||
_setVesselStateForTest({ enabled: false });
|
_setVesselStateForTest({ enabled: false });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an aid to navigation card shows its kind, never a speed or heading', () => {
|
||||||
|
const aid = makeRecord({
|
||||||
|
name: 'SF APPROACH N',
|
||||||
|
mmsi: '993672001',
|
||||||
|
type: 'Cardinal Mark N',
|
||||||
|
speed: 0,
|
||||||
|
course: 135,
|
||||||
|
heading: 135,
|
||||||
|
});
|
||||||
|
const card = buildVesselCard(aid);
|
||||||
|
assert.deepEqual(card.details, ['CARDINAL MARK N']);
|
||||||
|
assert.equal(card.accent, '217, 210, 192');
|
||||||
|
const selected = buildSelectedVesselCard(aid);
|
||||||
|
assert.equal(selected.details[0], 'CARDINAL MARK N');
|
||||||
|
assert.equal(selected.accent, '217, 210, 192');
|
||||||
|
// Known only by its MMSI, an aid is named as one.
|
||||||
|
const unnamed = buildSelectedVesselCard({ ...aid, type: '' });
|
||||||
|
assert.equal(unnamed.details[0], 'AID TO NAVIGATION');
|
||||||
|
assert.deepEqual(buildVesselCard({ ...aid, type: '' }).details, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the HUD of a selected aid to navigation shows no speed or heading', () => {
|
||||||
|
const harness = installWireHarness(undefined, { selectedRecord: null });
|
||||||
|
try {
|
||||||
|
Object.assign(harness.record, {
|
||||||
|
type: 'Port hand Mark',
|
||||||
|
speed: 0,
|
||||||
|
heading: 135,
|
||||||
|
course: 135,
|
||||||
|
});
|
||||||
|
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||||
|
assert.match(harness.hud.readout.text, /\nPort hand Mark\n/);
|
||||||
|
assert.doesNotMatch(harness.hud.readout.text, /SPD|HDG|KT/);
|
||||||
|
} finally {
|
||||||
|
harness.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -342,3 +342,49 @@ test('an aid-to-navigation report is a row named by its kind of aid, not a ship
|
|||||||
assert.equal(row.name, 'SF APPROACH N');
|
assert.equal(row.name, 'SF APPROACH N');
|
||||||
assert.equal(row.speed, null);
|
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')),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -46,6 +46,15 @@ const TYPE_STYLES = [
|
|||||||
];
|
];
|
||||||
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
|
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
|
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
|
||||||
// and tests match on them.
|
// and tests match on them.
|
||||||
const TYPE_DISPLAY_KEYS = Object.freeze({
|
const TYPE_DISPLAY_KEYS = Object.freeze({
|
||||||
|
|||||||
+34
-8
@@ -100,27 +100,53 @@ export function aidToNavigationTypeName(code) {
|
|||||||
return AID_TYPE_NAMES[index] ?? AID_TYPE_NAMES[0];
|
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
|
* Words a vessel type uses for an aid to navigation. A vessel that works on
|
||||||
* one ("Buoy Tender", "Buoy-Laying Vessel") is a ship underway, not a mark on
|
* one ("Buoy Tender", "Buoy-Laying Vessel", "Buoy Layer") is a ship underway,
|
||||||
* the water.
|
* not a mark on the water.
|
||||||
*/
|
*/
|
||||||
const AID_TYPE_TEXT =
|
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;
|
/navaid|\baton\b|aids? to nav|nav(igation)? aid|racon|lighthouse|lightship|light vessel|reference point|hand mark|cardinal|safe water|preferred channel|isolated danger|special mark|beacon|buoy/i;
|
||||||
const AID_WORKER_TEXT = /tender|laying/i;
|
const AID_WORKER_TEXT = /tender|lay(ing|er)\b/i;
|
||||||
|
|
||||||
/** ITU MID 99: the MMSI of an AIS aid-to-navigation station (99MIDxxxx). */
|
/** ITU MID 99: the MMSI of an AIS aid-to-navigation station (99MIDxxxx). */
|
||||||
const AID_MMSI = /^99\d{7}$/;
|
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,
|
* Whether a vessel record is an aid to navigation (a buoy, beacon, mark,
|
||||||
* RACON or light) rather than a vessel: its type names one, or its MMSI is
|
* RACON or light) rather than a vessel: its type is one of the kinds an aid
|
||||||
* an aid-to-navigation station's. A numeric AIS ship type never names one.
|
* report names or uses a word for one, or its MMSI is an aid-to-navigation
|
||||||
|
* station's. A numeric AIS ship type never names one.
|
||||||
* @param {{type?: string|number|null, mmsi?: string|number|null}} vessel
|
* @param {{type?: string|number|null, mmsi?: string|number|null}} vessel
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
export function isAidToNavigation({ type, mmsi } = {}) {
|
export function isAidToNavigation({ type, mmsi } = {}) {
|
||||||
const text = String(type ?? '').trim();
|
const text = typeof type === 'string' ? type.trim() : String(type ?? '');
|
||||||
if (AID_TYPE_TEXT.test(text) && !AID_WORKER_TEXT.test(text)) return true;
|
if (text && typeNamesAid(text)) return true;
|
||||||
return AID_MMSI.test(String(mmsi ?? '').trim());
|
if (mmsi === null || mmsi === undefined || mmsi === '') return false;
|
||||||
|
return AID_MMSI.test(typeof mmsi === 'string' ? mmsi.trim() : String(mmsi));
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,11 @@ test('aids to navigation are known by their type, else their MMSI', () => {
|
|||||||
'AtoN',
|
'AtoN',
|
||||||
'Lighthouse',
|
'Lighthouse',
|
||||||
'Buoy',
|
'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);
|
assert.equal(isAidToNavigation({ type, mmsi: '' }), true, type);
|
||||||
// AIS aid-to-navigation stations are numbered 99MIDxxxx.
|
// AIS aid-to-navigation stations are numbered 99MIDxxxx.
|
||||||
@@ -37,6 +42,10 @@ test('aids to navigation are known by their type, else their MMSI', () => {
|
|||||||
for (const vessel of [
|
for (const vessel of [
|
||||||
{ type: 'Buoy Tender', mmsi: '366999001' },
|
{ type: 'Buoy Tender', mmsi: '366999001' },
|
||||||
{ type: 'Buoy-Laying Vessel', mmsi: '' },
|
{ 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: '70', mmsi: '366999002' },
|
||||||
{ type: '0', mmsi: '' },
|
{ type: '0', mmsi: '' },
|
||||||
{ type: 'Cargo', mmsi: '99' },
|
{ type: 'Cargo', mmsi: '99' },
|
||||||
|
|||||||
@@ -776,6 +776,7 @@
|
|||||||
"TANKER": "ناقلة",
|
"TANKER": "ناقلة",
|
||||||
"OTHER": "أخرى",
|
"OTHER": "أخرى",
|
||||||
"VESSEL": "سفينة",
|
"VESSEL": "سفينة",
|
||||||
|
"AID TO NAVIGATION": "مساعدة ملاحية",
|
||||||
"POS: LIVE": "الموضع: مباشر",
|
"POS: LIVE": "الموضع: مباشر",
|
||||||
"POS: {time}Z": "الموضع: {time}Z",
|
"POS: {time}Z": "الموضع: {time}Z",
|
||||||
"AIS: --": "AIS: --",
|
"AIS: --": "AIS: --",
|
||||||
|
|||||||
@@ -416,6 +416,7 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "SCHIFF",
|
"VESSEL": "SCHIFF",
|
||||||
|
"AID TO NAVIGATION": "SEEZEICHEN",
|
||||||
"POS: LIVE": "POS: LIVE",
|
"POS: LIVE": "POS: LIVE",
|
||||||
"POS: {time}Z": "POS: {time}Z",
|
"POS: {time}Z": "POS: {time}Z",
|
||||||
"awaiting first AIS position…": "warten auf erste AIS-Position…",
|
"awaiting first AIS position…": "warten auf erste AIS-Position…",
|
||||||
|
|||||||
@@ -650,6 +650,7 @@
|
|||||||
"TANKER": "PETROLERO",
|
"TANKER": "PETROLERO",
|
||||||
"OTHER": "OTRO",
|
"OTHER": "OTRO",
|
||||||
"VESSEL": "BUQUE",
|
"VESSEL": "BUQUE",
|
||||||
|
"AID TO NAVIGATION": "AYUDA A LA NAVEGACIÓN",
|
||||||
"POS: LIVE": "POS: EN VIVO",
|
"POS: LIVE": "POS: EN VIVO",
|
||||||
"POS: {time}Z": "POS: {time}Z",
|
"POS: {time}Z": "POS: {time}Z",
|
||||||
"AIS: --": "AIS: --",
|
"AIS: --": "AIS: --",
|
||||||
|
|||||||
@@ -1291,6 +1291,7 @@
|
|||||||
"AIS: {name}": "AIS : {name}",
|
"AIS: {name}": "AIS : {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}",
|
||||||
"VESSEL": "NAVIRE",
|
"VESSEL": "NAVIRE",
|
||||||
|
"AID TO NAVIGATION": "AIDE À LA NAVIGATION",
|
||||||
"{speed}KT": "{speed} ND",
|
"{speed}KT": "{speed} ND",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS : DIRECT",
|
"POS: LIVE": "POS : DIRECT",
|
||||||
|
|||||||
@@ -1149,6 +1149,7 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "אונייה",
|
"VESSEL": "אונייה",
|
||||||
|
"AID TO NAVIGATION": "עזר ניווט",
|
||||||
"{speed}KT": "{speed} קשר",
|
"{speed}KT": "{speed} קשר",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: חי",
|
"POS: LIVE": "POS: חי",
|
||||||
|
|||||||
@@ -1291,6 +1291,7 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}",
|
||||||
"VESSEL": "NAVE",
|
"VESSEL": "NAVE",
|
||||||
|
"AID TO NAVIGATION": "AIUTO ALLA NAVIGAZIONE",
|
||||||
"{speed}KT": "{speed}KT",
|
"{speed}KT": "{speed}KT",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: LIVE",
|
"POS: LIVE": "POS: LIVE",
|
||||||
|
|||||||
@@ -1253,6 +1253,7 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "船舶",
|
"VESSEL": "船舶",
|
||||||
|
"AID TO NAVIGATION": "航路標識",
|
||||||
"{speed}KT": "{speed} kt",
|
"{speed}KT": "{speed} kt",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: ライブ",
|
"POS: LIVE": "POS: ライブ",
|
||||||
|
|||||||
@@ -1289,6 +1289,7 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "EMBARCAÇÃO",
|
"VESSEL": "EMBARCAÇÃO",
|
||||||
|
"AID TO NAVIGATION": "AUXÍLIO À NAVEGAÇÃO",
|
||||||
"{speed}KT": "{speed} KT",
|
"{speed}KT": "{speed} KT",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS: AO VIVO",
|
"POS: LIVE": "POS: AO VIVO",
|
||||||
|
|||||||
@@ -394,6 +394,7 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}",
|
||||||
"VESSEL": "СУДНО",
|
"VESSEL": "СУДНО",
|
||||||
|
"AID TO NAVIGATION": "СРЕДСТВО НАВИГАЦИИ",
|
||||||
"AIS: --": "AIS: --",
|
"AIS: --": "AIS: --",
|
||||||
"awaiting first AIS position…": "ожидание первой позиции AIS…",
|
"awaiting first AIS position…": "ожидание первой позиции AIS…",
|
||||||
"awaiting usable AIS positions…": "ожидание пригодных позиций AIS…",
|
"awaiting usable AIS positions…": "ожидание пригодных позиций AIS…",
|
||||||
|
|||||||
@@ -382,6 +382,7 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "FARTYG",
|
"VESSEL": "FARTYG",
|
||||||
|
"AID TO NAVIGATION": "SJÖMÄRKE",
|
||||||
"POS: LIVE": "POS: LIVE",
|
"POS: LIVE": "POS: LIVE",
|
||||||
"POS: {time}Z": "POS: {time}Z",
|
"POS: {time}Z": "POS: {time}Z",
|
||||||
"awaiting first AIS position…": "väntar på första AIS-positionen …",
|
"awaiting first AIS position…": "väntar på första AIS-positionen …",
|
||||||
|
|||||||
@@ -1309,6 +1309,7 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}",
|
||||||
"VESSEL": "СУДНО",
|
"VESSEL": "СУДНО",
|
||||||
|
"AID TO NAVIGATION": "ЗАСІБ НАВІГАЦІЇ",
|
||||||
"{speed}KT": "{speed} ВУЗ",
|
"{speed}KT": "{speed} ВУЗ",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "ПОЗ.: ОНЛАЙН",
|
"POS: LIVE": "ПОЗ.: ОНЛАЙН",
|
||||||
|
|||||||
@@ -1229,6 +1229,7 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "船舶",
|
"VESSEL": "船舶",
|
||||||
|
"AID TO NAVIGATION": "助航标志",
|
||||||
"{speed}KT": "{speed} kn",
|
"{speed}KT": "{speed} kn",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS:实时",
|
"POS: LIVE": "POS:实时",
|
||||||
|
|||||||
@@ -1224,6 +1224,7 @@
|
|||||||
"AIS: {name}": "AIS: {name}",
|
"AIS: {name}": "AIS: {name}",
|
||||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||||
"VESSEL": "船舶",
|
"VESSEL": "船舶",
|
||||||
|
"AID TO NAVIGATION": "助航標誌",
|
||||||
"{speed}KT": "{speed} kn",
|
"{speed}KT": "{speed} kn",
|
||||||
"{heading}DEG": "{heading}°",
|
"{heading}DEG": "{heading}°",
|
||||||
"POS: LIVE": "POS:實時",
|
"POS: LIVE": "POS:實時",
|
||||||
|
|||||||
+45
-20
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
AID_TO_NAVIGATION_STYLE,
|
||||||
accentForVesselType,
|
accentForVesselType,
|
||||||
displayVesselType,
|
displayVesselType,
|
||||||
normalizeVesselType,
|
normalizeVesselType,
|
||||||
} from '../../data/vesselLabels.js';
|
} from '../../data/vesselLabels.js';
|
||||||
|
import { isAidToNavigation } from '../../data/vesselTypes.js';
|
||||||
import { formatNumber, t } from '../../i18n/index.js';
|
import { formatNumber, t } from '../../i18n/index.js';
|
||||||
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
|
||||||
|
|
||||||
@@ -16,14 +18,18 @@ export function createCards({
|
|||||||
function updateSelectedVesselHud(record) {
|
function updateSelectedVesselHud(record) {
|
||||||
// Pinned vessels missing from recent refreshes get a stale marker
|
// Pinned vessels missing from recent refreshes get a stale marker
|
||||||
const stale = (record.missedRefreshes || 0) > 0;
|
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({
|
services.readouts?.presentSelectedVessel({
|
||||||
text: [
|
text: [
|
||||||
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
|
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
|
||||||
t('{type} SPD: {speed} HDG: {heading}', {
|
aid
|
||||||
type: trimHudValue(record.type || t('VESSEL'), 24),
|
? trimHudValue(record.type || t('AID TO NAVIGATION'), 24)
|
||||||
speed: formatSpeed(record.speed),
|
: t('{type} SPD: {speed} HDG: {heading}', {
|
||||||
heading: formatHeading(record.heading ?? record.course),
|
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')}` : ''}`,
|
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
});
|
});
|
||||||
@@ -43,20 +49,21 @@ export function createCards({
|
|||||||
/**
|
/**
|
||||||
* Card model for an ambient (decluttered-in) vessel — name title plus one
|
* Card model for an ambient (decluttered-in) vessel — name title plus one
|
||||||
* compact type/speed/heading detail line, anchored at the record's current
|
* compact type/speed/heading detail line, anchored at the record's current
|
||||||
* rendered position (height-datum caveat: no datum work here). Pure —
|
* rendered position (height-datum caveat: no datum work here). An aid to
|
||||||
* exported for unit tests.
|
* navigation's line is its kind alone. Pure — exported for unit tests.
|
||||||
* @param {Object} record - Vessel record.
|
* @param {Object} record - Vessel record.
|
||||||
* @returns {Object} vesselLabels entry.
|
* @returns {Object} vesselLabels entry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function buildVesselCard(record) {
|
function buildVesselCard(record) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
const aid = isAidToNavigation(record);
|
||||||
const type = vesselTypeShort(record);
|
const type = vesselTypeShort(record);
|
||||||
if (type) parts.push(type);
|
if (type) parts.push(type);
|
||||||
if (record.speed !== null && record.speed !== undefined)
|
if (!aid && record.speed !== null && record.speed !== undefined)
|
||||||
parts.push(formatSpeed(record.speed));
|
parts.push(formatSpeed(record.speed));
|
||||||
const direction = record.heading ?? record.course;
|
const direction = record.heading ?? record.course;
|
||||||
if (Number.isFinite(direction))
|
if (!aid && Number.isFinite(direction))
|
||||||
parts.push(`${roundHeadingDegrees(direction)}°`);
|
parts.push(`${roundHeadingDegrees(direction)}°`);
|
||||||
return {
|
return {
|
||||||
id: vesselOverlayEntryId(record),
|
id: vesselOverlayEntryId(record),
|
||||||
@@ -65,7 +72,7 @@ export function createCards({
|
|||||||
components.rendering.getVisual(record).billboard?.position ||
|
components.rendering.getVisual(record).billboard?.position ||
|
||||||
components.rendering.getVisual(record).position,
|
components.rendering.getVisual(record).position,
|
||||||
gapPx: 10,
|
gapPx: 10,
|
||||||
accent: accentForVesselType(record.type),
|
accent: vesselAccent(record, aid),
|
||||||
title: trimHudValue(displayVesselName(record), 26),
|
title: trimHudValue(displayVesselName(record), 26),
|
||||||
details: parts.length ? [parts.join(' · ')] : [],
|
details: parts.length ? [parts.join(' · ')] : [],
|
||||||
selected: false,
|
selected: false,
|
||||||
@@ -76,22 +83,26 @@ export function createCards({
|
|||||||
/**
|
/**
|
||||||
* Card model for the click-selected vessel — the full-detail card, drawn last
|
* 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
|
* (on top) and never distance-faded by the overlay. Pinned-but-vanished
|
||||||
* vessels carry a STALE marker (mirrors the HUD readout). Pure — exported
|
* vessels carry a STALE marker (mirrors the HUD readout). An aid to
|
||||||
* for unit tests.
|
* navigation shows its kind with no speed or heading. Pure — exported for
|
||||||
|
* unit tests.
|
||||||
* @param {Object} record - Selected vessel record.
|
* @param {Object} record - Selected vessel record.
|
||||||
* @returns {Object} vesselLabels entry.
|
* @returns {Object} vesselLabels entry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function buildSelectedVesselCard(record) {
|
function buildSelectedVesselCard(record) {
|
||||||
|
const aid = isAidToNavigation(record);
|
||||||
const direction = record.heading ?? record.course;
|
const direction = record.heading ?? record.course;
|
||||||
const details = [
|
const details = [
|
||||||
[
|
aid
|
||||||
vesselTypeShort(record) || t('VESSEL'),
|
? vesselTypeShort(record) || t('AID TO NAVIGATION')
|
||||||
formatSpeed(record.speed),
|
: [
|
||||||
Number.isFinite(direction)
|
vesselTypeShort(record) || t('VESSEL'),
|
||||||
? `${roundHeadingDegrees(direction)}°`
|
formatSpeed(record.speed),
|
||||||
: '--°',
|
Number.isFinite(direction)
|
||||||
].join(' · '),
|
? `${roundHeadingDegrees(direction)}°`
|
||||||
|
: '--°',
|
||||||
|
].join(' · '),
|
||||||
];
|
];
|
||||||
const destination = String(record.destination || '').trim();
|
const destination = String(record.destination || '').trim();
|
||||||
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
|
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
|
||||||
@@ -106,7 +117,7 @@ export function createCards({
|
|||||||
components.rendering.getVisual(record).billboard?.position ||
|
components.rendering.getVisual(record).billboard?.position ||
|
||||||
components.rendering.getVisual(record).position,
|
components.rendering.getVisual(record).position,
|
||||||
gapPx: 12,
|
gapPx: 12,
|
||||||
accent: accentForVesselType(record.type),
|
accent: vesselAccent(record, aid),
|
||||||
title: trimHudValue(displayVesselName(record), 32),
|
title: trimHudValue(displayVesselName(record), 32),
|
||||||
details,
|
details,
|
||||||
selected: true,
|
selected: true,
|
||||||
@@ -114,6 +125,14 @@ export function createCards({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A card's accent: an aid's disc colour, else its ship type's. */
|
||||||
|
|
||||||
|
function vesselAccent(record, aid) {
|
||||||
|
return aid
|
||||||
|
? AID_TO_NAVIGATION_STYLE.accent
|
||||||
|
: accentForVesselType(record.type);
|
||||||
|
}
|
||||||
|
|
||||||
/** Stable overlay identity for keyed and source-retained unkeyed rows. */
|
/** Stable overlay identity for keyed and source-retained unkeyed rows. */
|
||||||
|
|
||||||
function vesselOverlayEntryId(record) {
|
function vesselOverlayEntryId(record) {
|
||||||
@@ -129,9 +148,15 @@ export function createCards({
|
|||||||
* Uppercased AIS type for the card (empty string when unknown). Free feed
|
* 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
|
* 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").
|
* 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) {
|
function vesselTypeShort(record) {
|
||||||
|
if (isAidToNavigation(record)) {
|
||||||
|
const kind = String(record.type || '').trim();
|
||||||
|
return kind ? trimHudValue(kind.toUpperCase(), 26) : '';
|
||||||
|
}
|
||||||
const feedType = normalizeVesselType(record.type);
|
const feedType = normalizeVesselType(record.type);
|
||||||
const shown = displayVesselType(record.type);
|
const shown = displayVesselType(record.type);
|
||||||
return shown === feedType
|
return shown === feedType
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { t } from '../../i18n/index.js';
|
import { t } from '../../i18n/index.js';
|
||||||
import {
|
import {
|
||||||
|
AID_TO_NAVIGATION_STYLE,
|
||||||
vesselTypeCss,
|
vesselTypeCss,
|
||||||
vesselOverlayCohortLimit,
|
vesselOverlayCohortLimit,
|
||||||
VESSEL_OVERLAY_SOURCE_ID,
|
VESSEL_OVERLAY_SOURCE_ID,
|
||||||
@@ -30,9 +31,6 @@ import {
|
|||||||
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
|
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
|
||||||
import { isAidToNavigation } from '../../data/vesselTypes.js';
|
import { isAidToNavigation } from '../../data/vesselTypes.js';
|
||||||
|
|
||||||
/** An aid to navigation's disc: a bone white apart from every ship type's colour. */
|
|
||||||
const AID_ICON_CSS = '#d9d2c0';
|
|
||||||
|
|
||||||
/** How often vessels move on as the world clock plays (wall ms). */
|
/** How often vessels move on as the world clock plays (wall ms). */
|
||||||
const FOLLOW_POSITION_MS = 250;
|
const FOLLOW_POSITION_MS = 250;
|
||||||
|
|
||||||
@@ -213,7 +211,7 @@ export function createRendering({
|
|||||||
const cssColor = selected
|
const cssColor = selected
|
||||||
? '#ffffff'
|
? '#ffffff'
|
||||||
: aid
|
: aid
|
||||||
? AID_ICON_CSS
|
? AID_TO_NAVIGATION_STYLE.css
|
||||||
: vesselTypeCss(record.type);
|
: vesselTypeCss(record.type);
|
||||||
const key = `${cssColor}:${selected ? 'selected' : 'normal'}:${aid ? 'aid' : 'ship'}`;
|
const key = `${cssColor}:${selected ? 'selected' : 'normal'}:${aid ? 'aid' : 'ship'}`;
|
||||||
if (vesselState.shipIconCache.has(key))
|
if (vesselState.shipIconCache.has(key))
|
||||||
@@ -482,7 +480,8 @@ export function createRendering({
|
|||||||
visible &&
|
visible &&
|
||||||
record.id !== state.cockpitSubjectId &&
|
record.id !== state.cockpitSubjectId &&
|
||||||
!visual.modelOwnsVisual;
|
!visual.modelOwnsVisual;
|
||||||
if (visible && doRotations && scene) {
|
// An aid's disc is never turned (vesselCourseDeg).
|
||||||
|
if (visible && doRotations && scene && !isAidToNavigation(record)) {
|
||||||
const rot = screenProjectedRotation(
|
const rot = screenProjectedRotation(
|
||||||
scene,
|
scene,
|
||||||
visual.position,
|
visual.position,
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import {
|
|||||||
thinEvenly,
|
thinEvenly,
|
||||||
} from '../results.js';
|
} from '../results.js';
|
||||||
import { roundHeadingDegrees } from '../../data/headingDegrees.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({
|
const MMSI_SCHEMA = Object.freeze({
|
||||||
type: 'string',
|
type: 'string',
|
||||||
@@ -116,7 +119,8 @@ export const vesselsInArea = defineTool({
|
|||||||
title: 'Vessels in an area',
|
title: 'Vessels in an area',
|
||||||
description:
|
description:
|
||||||
'Ships currently reported by AIS inside an area, nearest the center ' +
|
'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 ' +
|
'ship by type or destination, and stats summarizes their speed; both ' +
|
||||||
'cover all matches, not just the rows returned.',
|
'cover all matches, not just the rows returned.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
@@ -152,6 +156,8 @@ export const vesselsInArea = defineTool({
|
|||||||
const rows = snapshot.records
|
const rows = snapshot.records
|
||||||
.filter(
|
.filter(
|
||||||
(record) =>
|
(record) =>
|
||||||
|
// A buoy or beacon in the feed is not a ship to count.
|
||||||
|
!isAidToNavigation(record) &&
|
||||||
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
||||||
(!wanted ||
|
(!wanted ||
|
||||||
(vesselType(record.type) ?? '').toLowerCase().includes(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 },
|
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