Files
gods-eye-view/src/layers/vessels/cards.js
T
German KhokhlovandClaude Opus 5.5 13efd411b6 refactor(i18n): key messages by English text; add Russian
Replace the semantic keys with the English source text, so code reads as the
UI does and English needs no catalog: t('Save keys'), tn(count, one, other)
for plurals, msg() for text stored in data, and bare data-i18n* markup that
translates the element's own English. Other languages are one flat JSON each.

- Simplify src/i18n: drop namespaces, the configurable locale pair
  (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE) and its build defines. Locale
  comes from ?lang=, the stored choice, then browser languages.
- Add scripts/i18n.mjs (npm run i18n): extracts every message from t/tn/msg
  and markup, and fails on missing, unused, placeholder-mismatched or
  wrongly-shaped plural entries. npm test runs it via catalog.test.mjs.
- Add Russian (src/i18n/locales/ru.json, 833 messages), started from the
  community catalog in #312 and reviewed against the current English.
- Fix data-i18n placed on parents that would wipe their children (cockpit
  LEVEL and local weather cells, first-run EXPLORE MANUALLY tile), and a
  doubled degree sign in the TRACK ACQUIRED signal.
- Rewrite docs/TRANSLATORS.md for the new workflow; drop sections the earlier
  merge resurrected in docs/CURRENT-STATE.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:48:56 +04:00

191 lines
6.4 KiB
JavaScript

import {
accentForVesselType,
normalizeVesselType,
} from '../../data/vesselLabels.js';
import { t } from '../../i18n/index.js';
export function createCards({
vesselState,
services,
parts: components,
layer,
options,
}) {
const { state } = vesselState;
function updateSelectedVesselHud(record) {
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
// Pinned vessels missing from recent refreshes get a stale marker
const stale = (record.missedRefreshes || 0) > 0;
el.classList.add('active');
el.textContent = [
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() {
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
el.classList.remove('active');
el.textContent = t('AIS: --');
}
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;
if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`);
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)}°` : '--°',
].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, card-width-bounded AIS type (empty string when unknown). */
function vesselTypeShort(record) {
return normalizeVesselType(record.type).toUpperCase().slice(0, 14);
}
/**
* 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 speed === null ? '--KT' : `${speed.toFixed(1)}KT`;
}
function formatHeading(heading) {
return Number.isFinite(heading) ? `${Math.round(heading)}DEG` : '--DEG';
}
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,
};
}