mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
207 lines
6.9 KiB
JavaScript
207 lines
6.9 KiB
JavaScript
import {
|
|
accentForVesselType,
|
|
displayVesselType,
|
|
normalizeVesselType,
|
|
} from '../../data/vesselLabels.js';
|
|
import { formatNumber, t } from '../../i18n/index.js';
|
|
|
|
export function createCards({
|
|
vesselState,
|
|
services,
|
|
parts: components,
|
|
layer,
|
|
options,
|
|
}) {
|
|
function updateSelectedVesselHud(record) {
|
|
// Pinned vessels missing from recent refreshes get a stale marker
|
|
const stale = (record.missedRefreshes || 0) > 0;
|
|
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),
|
|
}),
|
|
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
|
|
].join('\n'),
|
|
});
|
|
}
|
|
|
|
function resetSelectedVesselHud() {
|
|
services.readouts?.presentSelectedVessel(null);
|
|
}
|
|
|
|
function trimHudValue(value, maxLength) {
|
|
const text = String(value || '--').trim() || '--';
|
|
return text.length > maxLength
|
|
? `${text.slice(0, maxLength - 3)}...`
|
|
: text;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* @param {Object} record - Vessel record.
|
|
* @returns {Object} vesselLabels entry.
|
|
*/
|
|
|
|
function buildVesselCard(record) {
|
|
const parts = [];
|
|
const type = vesselTypeShort(record);
|
|
if (type) parts.push(type);
|
|
if (record.speed !== null && record.speed !== undefined)
|
|
parts.push(formatSpeed(record.speed));
|
|
const direction = record.heading ?? record.course;
|
|
// A course of 359.5° and above rounds to north; modulo keeps it from
|
|
// printing 360°. The selected card and the HUD wrap the same way.
|
|
if (Number.isFinite(direction))
|
|
parts.push(`${Math.round(direction) % 360}°`);
|
|
return {
|
|
id: vesselOverlayEntryId(record),
|
|
actionable: Boolean(record?.mmsi),
|
|
position:
|
|
components.rendering.getVisual(record).billboard?.position ||
|
|
components.rendering.getVisual(record).position,
|
|
gapPx: 10,
|
|
accent: accentForVesselType(record.type),
|
|
title: trimHudValue(displayVesselName(record), 26),
|
|
details: parts.length ? [parts.join(' · ')] : [],
|
|
selected: false,
|
|
priority: components.rendering.labelPriority(record, null),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* @param {Object} record - Selected vessel record.
|
|
* @returns {Object} vesselLabels entry.
|
|
*/
|
|
|
|
function buildSelectedVesselCard(record) {
|
|
const direction = record.heading ?? record.course;
|
|
const details = [
|
|
[
|
|
vesselTypeShort(record) || t('VESSEL'),
|
|
formatSpeed(record.speed),
|
|
Number.isFinite(direction) ? `${Math.round(direction) % 360}°` : '--°',
|
|
].join(' · '),
|
|
];
|
|
const destination = String(record.destination || '').trim();
|
|
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
|
|
const stale = (record.missedRefreshes || 0) > 0;
|
|
details.push(
|
|
`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
|
|
);
|
|
return {
|
|
id: vesselOverlayEntryId(record),
|
|
actionable: Boolean(record?.mmsi),
|
|
position:
|
|
components.rendering.getVisual(record).billboard?.position ||
|
|
components.rendering.getVisual(record).position,
|
|
gapPx: 12,
|
|
accent: accentForVesselType(record.type),
|
|
title: trimHudValue(displayVesselName(record), 32),
|
|
details,
|
|
selected: true,
|
|
priority: 100000,
|
|
};
|
|
}
|
|
|
|
/** Stable overlay identity for MMSI-keyed and source-retained unkeyed rows. */
|
|
|
|
function vesselOverlayEntryId(record) {
|
|
const mmsi = String(record?.mmsi || '').trim();
|
|
if (mmsi) return `vessel:${mmsi}`;
|
|
const name = String(record?.name || 'VESSEL').trim() || 'VESSEL';
|
|
const lat = Number.isFinite(record?.lat) ? record.lat.toFixed(5) : 'x';
|
|
const lon = Number.isFinite(record?.lon) ? record.lon.toFixed(5) : 'x';
|
|
return `vessel:unkeyed:${name}:${lat}:${lon}`;
|
|
}
|
|
|
|
/**
|
|
* 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").
|
|
*/
|
|
|
|
function vesselTypeShort(record) {
|
|
const feedType = normalizeVesselType(record.type);
|
|
const shown = displayVesselType(record.type);
|
|
return shown === feedType
|
|
? feedType.toUpperCase().slice(0, 14)
|
|
: shown.toUpperCase();
|
|
}
|
|
|
|
/**
|
|
* True when `screen` is at least `minSepPx` away from every accepted screen
|
|
* position (greedy card-declutter accept test, mirroring the FIRMS pass).
|
|
* Exported for unit tests.
|
|
* @param {Array<{x: number, y: number}>} accepted - Accepted card positions.
|
|
* @param {{x: number, y: number}} screen - Candidate window coordinates.
|
|
* @param {number} minSepPx - Minimum separation in pixels.
|
|
* @returns {boolean}
|
|
*/
|
|
|
|
function cardScreenSeparated(accepted, screen, minSepPx) {
|
|
const minSq = minSepPx * minSepPx;
|
|
for (let i = 0; i < accepted.length; i += 1) {
|
|
const dx = screen.x - accepted[i].x;
|
|
const dy = screen.y - accepted[i].y;
|
|
if (dx * dx + dy * dy < minSq) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function displayVesselName(record) {
|
|
const name = String(record.name || '').trim();
|
|
if (name && name !== 'VESSEL' && name !== record.mmsi) return name;
|
|
return record.mmsi ? `MMSI ${record.mmsi}` : t('VESSEL');
|
|
}
|
|
|
|
function formatSpeed(speed) {
|
|
return t('{speed}KT', {
|
|
speed:
|
|
speed === null
|
|
? '--'
|
|
: formatNumber(speed, {
|
|
minimumFractionDigits: 1,
|
|
maximumFractionDigits: 1,
|
|
useGrouping: false,
|
|
}),
|
|
});
|
|
}
|
|
|
|
function formatHeading(heading) {
|
|
return t('{heading}DEG', {
|
|
heading: Number.isFinite(heading) ? Math.round(heading) % 360 : '--',
|
|
});
|
|
}
|
|
|
|
function formatPositionTime(record) {
|
|
if (!record.lastPositionUtc) return t('POS: LIVE');
|
|
const date = new Date(record.lastPositionUtc);
|
|
if (Number.isNaN(date.getTime())) return t('POS: LIVE');
|
|
return t('POS: {time}Z', { time: date.toISOString().slice(11, 19) });
|
|
}
|
|
return {
|
|
updateSelectedVesselHud,
|
|
resetSelectedVesselHud,
|
|
trimHudValue,
|
|
buildVesselCard,
|
|
buildSelectedVesselCard,
|
|
vesselOverlayEntryId,
|
|
vesselTypeShort,
|
|
cardScreenSeparated,
|
|
displayVesselName,
|
|
formatSpeed,
|
|
formatHeading,
|
|
formatPositionTime,
|
|
};
|
|
}
|