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]>
This commit is contained in:
quanticsoul4772andClaude Opus 5.5 committed 2026-10-17 15:49:02 -07:00
1 parent d06e485c12
commit d39256e700
6 files changed
+126 -48

No files matched your search

+2 -1
View File
@@ -4,5 +4,6 @@
of estimating from the returned rows. Airlines come from the ICAO designator of estimating from the returned rows. Airlines come from the ICAO designator
in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds in each callsign (SWA1234 counts as SWA), so the breakdown works on feeds
that report no operator; operator and type are counted only where the feed that report no operator; operator and type are counted only where the feed
reports them; AIS ship type 0 ("not available") counts as unknown reports them. Ship types count by family, so AIS codes 70 to 79 are all
cargo, and type 0 ("not available") counts as unknown
(quanticsoul4772, #1056). (quanticsoul4772, #1056).
+4
View File
@@ -1040,6 +1040,7 @@
"src/layers/vessels/tracking.js", "src/layers/vessels/tracking.js",
"src/data/iconOrientation.js", "src/data/iconOrientation.js",
"src/data/vesselLabels.js", "src/data/vesselLabels.js",
"src/data/vesselTypes.js",
"src/layers/vessels/records.js", "src/layers/vessels/records.js",
"src/layers/vessels/recordPolicy.js", "src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js", "src/layers/vessels/snapshotRenderer.js",
@@ -1841,6 +1842,7 @@
"src/data/trafficQueue.js", "src/data/trafficQueue.js",
"src/data/trailRenderer.js", "src/data/trailRenderer.js",
"src/data/vesselLabels.js", "src/data/vesselLabels.js",
"src/data/vesselTypes.js",
"src/firstRunExperience.js", "src/firstRunExperience.js",
"src/hud.js", "src/hud.js",
"src/hudLocality.js", "src/hudLocality.js",
@@ -2629,6 +2631,7 @@
"src/data/trafficQueue.js", "src/data/trafficQueue.js",
"src/data/trailRenderer.js", "src/data/trailRenderer.js",
"src/data/vesselLabels.js", "src/data/vesselLabels.js",
"src/data/vesselTypes.js",
"src/keylessGeocoder.js", "src/keylessGeocoder.js",
"src/layers/aircraft/classification.js", "src/layers/aircraft/classification.js",
"src/layers/alpr/index.js", "src/layers/alpr/index.js",
@@ -3782,6 +3785,7 @@
"src/tools/queries/maritime.js", "src/tools/queries/maritime.js",
"src/tools/queries/share.js", "src/tools/queries/share.js",
"src/data/idSwitches.js", "src/data/idSwitches.js",
"src/data/vesselTypes.js",
"src/data/layerState.js", "src/data/layerState.js",
"src/data/layerStateTokenReservations.json", "src/data/layerStateTokenReservations.json",
"src/sources/gbfsStations.js", "src/sources/gbfsStations.js",
+3 -40
View File
@@ -5,6 +5,9 @@
*/ */
import { t, msg } from '../i18n/index.js'; import { t, msg } from '../i18n/index.js';
import { normalizeVesselType } from './vesselTypes.js';
export { normalizeVesselType };
export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels'; export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels';
/** Existing selector grid size; one ambient winner is retained per cell. */ /** Existing selector grid size; one ambient winner is retained per cell. */
@@ -43,31 +46,6 @@ const TYPE_STYLES = [
]; ];
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' }; const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
const NUMERIC_TYPE_SPECIALS = {
30: 'FISHING',
31: 'TOWING',
32: 'TOWING',
33: 'DREDGER',
34: 'DIVE OPS',
35: 'MILITARY',
36: 'SAILING',
37: 'PLEASURE',
50: 'PILOT',
51: 'SAR',
52: 'TUG',
53: 'PORT TENDER',
54: 'ANTI-POLLUTION',
55: 'LAW ENFORCE',
58: 'MEDICAL',
};
const NUMERIC_TYPE_FAMILIES = {
4: 'HIGH-SPEED',
6: 'PASSENGER',
7: 'CARGO',
8: 'TANKER',
9: 'OTHER',
};
// 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({
@@ -104,21 +82,6 @@ export function displayVesselType(type) {
return key ? t(key) : normalized; return key ? t(key) : normalized;
} }
/**
* Resolve an AIS type to display text: bare numeric ship-type codes map to
* family names ("71" → "CARGO"); text types pass through unchanged.
* @param {string} type Raw AIS type.
* @returns {string}
*/
export function normalizeVesselType(type) {
const text = String(type || '').trim();
if (!text || !/^\d{1,2}$/.test(text)) return text;
const code = Number(text);
if (code <= 0) return '';
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
}
/** AIS ship type → CSS hex hue for the billboard chevron. */ /** AIS ship type → CSS hex hue for the billboard chevron. */
export function vesselTypeCss(type) { export function vesselTypeCss(type) {
return styleForType(type).css; return styleForType(type).css;
+45
View File
@@ -0,0 +1,45 @@
/**
* @module vesselTypes
* @description AIS ship-type codes (ITU-R M.1371) to family names, with no
* browser or i18n dependencies so the server-side query tools can use it.
*/
const NUMERIC_TYPE_SPECIALS = {
30: 'FISHING',
31: 'TOWING',
32: 'TOWING',
33: 'DREDGER',
34: 'DIVE OPS',
35: 'MILITARY',
36: 'SAILING',
37: 'PLEASURE',
50: 'PILOT',
51: 'SAR',
52: 'TUG',
53: 'PORT TENDER',
54: 'ANTI-POLLUTION',
55: 'LAW ENFORCE',
58: 'MEDICAL',
};
const NUMERIC_TYPE_FAMILIES = {
4: 'HIGH-SPEED',
6: 'PASSENGER',
7: 'CARGO',
8: 'TANKER',
9: 'OTHER',
};
/**
* Resolve an AIS type to display text: bare numeric ship-type codes map to
* family names ("71" → "CARGO"); text types pass through unchanged.
* @param {string} type Raw AIS type.
* @returns {string}
*/
export function normalizeVesselType(type) {
const text = String(type || '').trim();
if (!text || !/^\d{1,2}$/.test(text)) return text;
const code = Number(text);
if (code <= 0) return '';
if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code];
return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER';
}
+15 -7
View File
@@ -20,6 +20,7 @@ import {
numericSummary, numericSummary,
thinEvenly, thinEvenly,
} from '../results.js'; } from '../results.js';
import { normalizeVesselType } from '../../data/vesselTypes.js';
const MMSI_SCHEMA = Object.freeze({ const MMSI_SCHEMA = Object.freeze({
type: 'string', type: 'string',
@@ -32,11 +33,17 @@ const ALL_VESSELS = 1_000_000;
const round = (value, digits) => const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null; Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
const text = (value) => (typeof value === 'string' && value ? value : null); const text = (value) => (typeof value === 'string' && value ? value : null);
// AIS ship type 0 is "not available" (ITU-R M.1371); the source keeps it as // AIS sends ship types as ITU-R M.1371 codes ("70" to "79" are all cargo), so
// '0', so it is reported as unknown rather than counted as a type. // a code reads as its family ("CARGO"), which is what group_by counts and the
const vesselType = (value) => { // type filter matches; text types pass through. Type 0 is "not available" and
const type = text(value); // reads as unknown.
return type === '0' ? null : type; const vesselType = (value) => normalizeVesselType(text(value) ?? '') || null;
// Crews type the destination by hand; one with no letters ("0", "---") names
// no port and reads as unknown.
const vesselDestination = (value) => {
const destination = text(value);
return destination && /\p{L}/u.test(destination) ? destination : null;
}; };
function vesselRow(record, from) { function vesselRow(record, from) {
@@ -46,7 +53,7 @@ function vesselRow(record, from) {
name: text(record.name), name: text(record.name),
imo: text(record.imo), imo: text(record.imo),
type: vesselType(record.type), type: vesselType(record.type),
destination: text(record.destination), destination: vesselDestination(record.destination),
lat: round(record.latitude, 5), lat: round(record.latitude, 5),
lon: round(record.longitude, 5), lon: round(record.longitude, 5),
speed_mps: round(record.speedMps, 1), speed_mps: round(record.speedMps, 1),
@@ -134,7 +141,8 @@ export const vesselsInArea = defineTool({
.filter( .filter(
(record) => (record) =>
areaContains(area, { lat: record.latitude, lon: record.longitude }) && areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
(!wanted || String(record.type).toLowerCase().includes(wanted)), (!wanted ||
(vesselType(record.type) ?? '').toLowerCase().includes(wanted)),
) )
.map((record) => vesselRow(record, center)) .map((record) => vesselRow(record, center))
.sort((a, b) => a.distance_km - b.distance_km); .sort((a, b) => a.distance_km - b.distance_km);
+57
View File
@@ -263,6 +263,63 @@ test('AIS type 0 (not available) is counted as unknown, not as a type', async ()
); );
}); });
test('numeric AIS ship-type codes group and filter by their family', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
vessels: vessels({
records: [
vessel('366000001', 37.78, -122.4, { type: '70' }),
vessel('366000002', 37.79, -122.41, { type: '74' }),
vessel('366000003', 37.77, -122.39, { type: '79' }),
vessel('366000004', 37.76, -122.38, { type: '80' }),
vessel('366000005', 37.75, -122.37, { type: '52' }),
],
}),
},
});
const grouped = await catalog.call('vessels_in_area', {
area: bay,
group_by: 'type',
});
assert.deepEqual(grouped.data.breakdown.groups, [
{ value: 'CARGO', count: 3 },
{ value: 'TANKER', count: 1 },
{ value: 'TUG', count: 1 },
]);
const tankers = await catalog.call('vessels_in_area', {
area: bay,
type: 'tanker',
});
assert.deepEqual(
tankers.data.rows.map((row) => [row.mmsi, row.type]),
[['366000004', 'TANKER']],
);
});
test('a destination with no letters counts as unknown', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
vessels: vessels({
records: [
vessel('366000001', 37.78, -122.4, { destination: '0' }),
vessel('366000002', 37.79, -122.41, { destination: '---' }),
vessel('366000003', 37.77, -122.39, { destination: 'NLRTM' }),
],
}),
},
});
const result = await catalog.call('vessels_in_area', {
area: bay,
group_by: 'destination',
});
assert.deepEqual(result.data.breakdown.groups, [
{ value: 'NLRTM', count: 1 },
]);
assert.equal(result.data.breakdown.unknown_count, 2);
});
test('vessels in an area can be counted by destination with a speed summary', async () => { test('vessels in an area can be counted by destination with a speed summary', async () => {
const catalog = composeCatalog({ const catalog = composeCatalog({
tools: coreTools, tools: coreTools,