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
@@ -1882,3 +1882,41 @@ 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/);
|
||||
} 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.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' };
|
||||
|
||||
/**
|
||||
* 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({
|
||||
|
||||
+34
-8
@@ -100,27 +100,53 @@ export function aidToNavigationTypeName(code) {
|
||||
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") is a ship underway, not a mark on
|
||||
* the water.
|
||||
* 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|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). */
|
||||
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 names one, or its MMSI is
|
||||
* an aid-to-navigation station's. A numeric AIS ship type never names one.
|
||||
* 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. A numeric AIS ship type never names one.
|
||||
* @param {{type?: string|number|null, mmsi?: string|number|null}} vessel
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAidToNavigation({ type, mmsi } = {}) {
|
||||
const text = String(type ?? '').trim();
|
||||
if (AID_TYPE_TEXT.test(text) && !AID_WORKER_TEXT.test(text)) return true;
|
||||
return AID_MMSI.test(String(mmsi ?? '').trim());
|
||||
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));
|
||||
}
|
||||
@@ -29,6 +29,11 @@ test('aids to navigation are known by their type, else their MMSI', () => {
|
||||
'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);
|
||||
// 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 [
|
||||
{ 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' },
|
||||
|
||||
@@ -776,6 +776,7 @@
|
||||
"TANKER": "ناقلة",
|
||||
"OTHER": "أخرى",
|
||||
"VESSEL": "سفينة",
|
||||
"AID TO NAVIGATION": "مساعدة ملاحية",
|
||||
"POS: LIVE": "الموضع: مباشر",
|
||||
"POS: {time}Z": "الموضع: {time}Z",
|
||||
"AIS: --": "AIS: --",
|
||||
|
||||
@@ -416,6 +416,7 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||
"VESSEL": "SCHIFF",
|
||||
"AID TO NAVIGATION": "SEEZEICHEN",
|
||||
"POS: LIVE": "POS: LIVE",
|
||||
"POS: {time}Z": "POS: {time}Z",
|
||||
"awaiting first AIS position…": "warten auf erste AIS-Position…",
|
||||
|
||||
@@ -650,6 +650,7 @@
|
||||
"TANKER": "PETROLERO",
|
||||
"OTHER": "OTRO",
|
||||
"VESSEL": "BUQUE",
|
||||
"AID TO NAVIGATION": "AYUDA A LA NAVEGACIÓN",
|
||||
"POS: LIVE": "POS: EN VIVO",
|
||||
"POS: {time}Z": "POS: {time}Z",
|
||||
"AIS: --": "AIS: --",
|
||||
|
||||
@@ -1291,6 +1291,7 @@
|
||||
"AIS: {name}": "AIS : {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}",
|
||||
"VESSEL": "NAVIRE",
|
||||
"AID TO NAVIGATION": "AIDE À LA NAVIGATION",
|
||||
"{speed}KT": "{speed} ND",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "POS : DIRECT",
|
||||
|
||||
@@ -1149,6 +1149,7 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||
"VESSEL": "אונייה",
|
||||
"AID TO NAVIGATION": "עזר ניווט",
|
||||
"{speed}KT": "{speed} קשר",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "POS: חי",
|
||||
|
||||
@@ -1291,6 +1291,7 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}",
|
||||
"VESSEL": "NAVE",
|
||||
"AID TO NAVIGATION": "AIUTO ALLA NAVIGAZIONE",
|
||||
"{speed}KT": "{speed}KT",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "POS: LIVE",
|
||||
|
||||
@@ -1253,6 +1253,7 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||
"VESSEL": "船舶",
|
||||
"AID TO NAVIGATION": "航路標識",
|
||||
"{speed}KT": "{speed} kt",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "POS: ライブ",
|
||||
|
||||
@@ -1289,6 +1289,7 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||
"VESSEL": "EMBARCAÇÃO",
|
||||
"AID TO NAVIGATION": "AUXÍLIO À NAVEGAÇÃO",
|
||||
"{speed}KT": "{speed} KT",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "POS: AO VIVO",
|
||||
|
||||
@@ -394,6 +394,7 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}",
|
||||
"VESSEL": "СУДНО",
|
||||
"AID TO NAVIGATION": "СРЕДСТВО НАВИГАЦИИ",
|
||||
"AIS: --": "AIS: --",
|
||||
"awaiting first AIS position…": "ожидание первой позиции AIS…",
|
||||
"awaiting usable AIS positions…": "ожидание пригодных позиций AIS…",
|
||||
|
||||
@@ -382,6 +382,7 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||
"VESSEL": "FARTYG",
|
||||
"AID TO NAVIGATION": "SJÖMÄRKE",
|
||||
"POS: LIVE": "POS: LIVE",
|
||||
"POS: {time}Z": "POS: {time}Z",
|
||||
"awaiting first AIS position…": "väntar på första AIS-positionen …",
|
||||
|
||||
@@ -1309,6 +1309,7 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}",
|
||||
"VESSEL": "СУДНО",
|
||||
"AID TO NAVIGATION": "ЗАСІБ НАВІГАЦІЇ",
|
||||
"{speed}KT": "{speed} ВУЗ",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "ПОЗ.: ОНЛАЙН",
|
||||
|
||||
@@ -1229,6 +1229,7 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||
"VESSEL": "船舶",
|
||||
"AID TO NAVIGATION": "助航标志",
|
||||
"{speed}KT": "{speed} kn",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "POS:实时",
|
||||
|
||||
@@ -1224,6 +1224,7 @@
|
||||
"AIS: {name}": "AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}",
|
||||
"VESSEL": "船舶",
|
||||
"AID TO NAVIGATION": "助航標誌",
|
||||
"{speed}KT": "{speed} kn",
|
||||
"{heading}DEG": "{heading}°",
|
||||
"POS: LIVE": "POS:實時",
|
||||
|
||||
+45
-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,18 @@ 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
|
||||
? trimHudValue(record.type || t('AID TO NAVIGATION'), 24)
|
||||
: 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 +49,21 @@ 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 (!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 +72,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 +83,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
|
||||
? vesselTypeShort(record) || t('AID TO NAVIGATION')
|
||||
: [
|
||||
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 +117,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 +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. */
|
||||
|
||||
function vesselOverlayEntryId(record) {
|
||||
@@ -129,9 +148,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
|
||||
|
||||
@@ -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,
|
||||
@@ -30,9 +31,6 @@ import {
|
||||
import { createFleetModelLayer } from '../../data/fleetModelInstances.js';
|
||||
import { isAidToNavigation } from '../../data/vesselTypes.js';
|
||||
|
||||
/** An aid to navigation's disc: a bone white apart from every ship type's colour. */
|
||||
const AID_ICON_CSS = '#d9d2c0';
|
||||
|
||||
/** How often vessels move on as the world clock plays (wall ms). */
|
||||
const FOLLOW_POSITION_MS = 250;
|
||||
|
||||
@@ -213,7 +211,7 @@ export function createRendering({
|
||||
const cssColor = selected
|
||||
? '#ffffff'
|
||||
: aid
|
||||
? AID_ICON_CSS
|
||||
? AID_TO_NAVIGATION_STYLE.css
|
||||
: vesselTypeCss(record.type);
|
||||
const key = `${cssColor}:${selected ? 'selected' : 'normal'}:${aid ? 'aid' : 'ship'}`;
|
||||
if (vesselState.shipIconCache.has(key))
|
||||
@@ -482,7 +480,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,
|
||||
|
||||
@@ -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