mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
d06e485c12
commit
d39256e700
6 files changed
+126
-48
No files matched your search
@@ -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).
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user