Files
gods-eye-view/src/data/vesselLabels.js
T
quanticsoul4772andClaude Opus 5.5 d39256e700 fix(tools): ship types read as their family; letterless destinations are unknown
AIS sends ship types as ITU-R M.1371 codes, so vessels_in_area grouped
"70", "74" and "79" as three types, and type: "tanker" matched nothing on
feeds that send codes. Codes now read as the family the vessel cards already
show (CARGO, TANKER, TUG), for rows, group_by and the type filter. The code
table moves to src/data/vesselTypes.js, which has no i18n import, so the
server-side tools can use it; vesselLabels re-exports it.

A destination with no letters ("0", "---") now counts as unknown.

Live, Singapore: "most by type: CARGO (3), TANKER (3), PASSENGER (1)"; "how
many tankers near Singapore" found 3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 15:49:02 -07:00

162 lines
5.3 KiB
JavaScript

/**
* @module vesselLabels
* @description AIS type formatting and shared world-overlay policy retained
* after the vessel cards moved out of their dedicated canvas renderer.
*/
import { t, msg } from '../i18n/index.js';
import { normalizeVesselType } from './vesselTypes.js';
export { normalizeVesselType };
export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels';
/** Existing selector grid size; one ambient winner is retained per cell. */
export const VESSEL_LABEL_GRID_PX = 118;
/** Existing environment-default ceiling for ambient vessel rows. */
export const VESSEL_DEFAULT_LABEL_LIMIT = 900;
/** Existing configured absolute ceiling; viewport grid demand is usually lower. */
export const VESSEL_OVERLAY_MAX_COHORT = VESSEL_DEFAULT_LABEL_LIMIT;
/** Existing ambient vessel-card distance fade reaches zero at 5000 km. */
export const VESSEL_CARD_FADE_DISTANCE_M = 5_000_000;
/** Share of VESSEL_CARD_FADE_DISTANCE_M at which ambient cards begin to fade. */
export const VESSEL_CARD_FADE_START_RATIO = 0.7;
/**
* AIS type family → chevron hue + card accent. Single source of truth for
* vessel type colors so billboard chevrons and host cards cannot drift apart.
*/
const TYPE_STYLES = [
{ pattern: /tanker/i, css: '#ffb347', accent: '255, 179, 71' },
{
pattern: /cargo|container|bulk|carrier/i,
css: '#39d5ff',
accent: '57, 213, 255',
},
{
pattern: /passenger|ferry|cruise/i,
css: '#ff7adf',
accent: '255, 122, 223',
},
{ pattern: /fishing/i, css: '#7cff9b', accent: '124, 255, 155' },
{
pattern: /tug|tow|pilot|supply|service/i,
css: '#f7f0a3',
accent: '247, 240, 163',
},
];
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
// Card text for the type tokens above. The tokens stay English: TYPE_STYLES
// and tests match on them.
const TYPE_DISPLAY_KEYS = Object.freeze({
FISHING: msg('FISHING'),
TOWING: msg('TOWING'),
DREDGER: msg('DREDGER'),
'DIVE OPS': msg('DIVE OPS'),
MILITARY: msg('MILITARY'),
SAILING: msg('SAILING'),
PLEASURE: msg('PLEASURE'),
PILOT: msg('PILOT'),
SAR: msg('SAR'),
TUG: msg('TUG'),
'PORT TENDER': msg('PORT TENDER'),
'ANTI-POLLUTION': msg('ANTI-POLLUTION'),
'LAW ENFORCE': msg('LAW ENFORCE'),
MEDICAL: msg('MEDICAL'),
'HIGH-SPEED': msg('HIGH-SPEED'),
PASSENGER: msg('PASSENGER'),
CARGO: msg('CARGO'),
TANKER: msg('TANKER'),
OTHER: msg('OTHER'),
});
/**
* Card-display form of a normalized AIS type: known tokens translate through
* the catalog; free-text types and unknown codes render unchanged.
* @param {string} type Raw AIS type.
* @returns {string}
*/
export function displayVesselType(type) {
const normalized = normalizeVesselType(type);
const key = TYPE_DISPLAY_KEYS[normalized.toUpperCase()];
return key ? t(key) : normalized;
}
/** AIS ship type → CSS hex hue for the billboard chevron. */
export function vesselTypeCss(type) {
return styleForType(type).css;
}
/** AIS ship type → "r, g, b" accent string for the host card. */
export function accentForVesselType(type) {
return styleForType(type).accent;
}
function styleForType(type) {
const text = normalizeVesselType(type);
return TYPE_STYLES.find((entry) => entry.pattern.test(text)) || DEFAULT_STYLE;
}
/**
* Derive the source's ambient cohort from the shipped selector grid. This is
* an upper bound; the existing greedy 150 px separation usually yields fewer.
* Selected vessels are protected and do not consume this budget.
* @param {number} width CSS viewport width.
* @param {number} height CSS viewport height.
* @param {number} [rowLimit=900] Configured source row ceiling.
* @returns {number}
*/
export function vesselOverlayCohortLimit(
width,
height,
rowLimit = VESSEL_DEFAULT_LABEL_LIMIT,
) {
const w = Number(width);
const h = Number(height);
const requested = Number(rowLimit);
if (!(w > 0) || !(h > 0) || !(requested > 0)) return 0;
const gridCapacity =
Math.ceil(w / VESSEL_LABEL_GRID_PX) * Math.ceil(h / VESSEL_LABEL_GRID_PX);
return Math.min(
VESSEL_OVERLAY_MAX_COHORT,
Math.floor(requested),
gridCapacity,
);
}
/**
* Add host-owned layout, fade, collision and paint-lane fields to a formatted
* vessel card. Ambient and selected cards share `ambient-card`, so the host's
* protected selected rectangle excludes ambient cards while bypassing quotas.
* @param {Object} card Source-formatted vessel card.
* @param {number} [fadeDistance=5000000] Ambient distance-fade endpoint.
* @returns {Object}
*/
export function applyVesselOverlayPolicy(
card,
fadeDistance = VESSEL_CARD_FADE_DISTANCE_M,
) {
const selected = card?.selected === true;
const rawGap = Number(card?.gapPx) || 10;
const gapPx = Math.max(selected ? 28 : 12, rawGap + 8);
return {
...card,
variant: selected ? 'selected' : 'card',
protected: selected,
collisionGroup: 'ambient-card',
cardStyle: 'tactical',
gapPx,
leaderOffsetPx: Math.max(2, gapPx - 6),
// Selected detail cards may move beside the contact to clear solid panels.
verticalOnly: !selected,
viewportMargin: 4,
maxDistance: selected ? Number.POSITIVE_INFINITY : fadeDistance,
distanceFadeStartRatio: VESSEL_CARD_FADE_START_RATIO,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
// Only MMSI-keyed cards can resolve back to one actionable vessel.
interactive: card?.actionable === true,
};
}