diff --git a/changelog.d/1056-area-tool-breakdowns.md b/changelog.d/1056-area-tool-breakdowns.md index 67ec8d766..5425b3651 100644 --- a/changelog.d/1056-area-tool-breakdowns.md +++ b/changelog.d/1056-area-tool-breakdowns.md @@ -4,5 +4,6 @@ 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 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). diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 43bc3d4de..07f2545a3 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1040,6 +1040,7 @@ "src/layers/vessels/tracking.js", "src/data/iconOrientation.js", "src/data/vesselLabels.js", + "src/data/vesselTypes.js", "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", "src/layers/vessels/snapshotRenderer.js", @@ -1841,6 +1842,7 @@ "src/data/trafficQueue.js", "src/data/trailRenderer.js", "src/data/vesselLabels.js", + "src/data/vesselTypes.js", "src/firstRunExperience.js", "src/hud.js", "src/hudLocality.js", @@ -2629,6 +2631,7 @@ "src/data/trafficQueue.js", "src/data/trailRenderer.js", "src/data/vesselLabels.js", + "src/data/vesselTypes.js", "src/keylessGeocoder.js", "src/layers/aircraft/classification.js", "src/layers/alpr/index.js", @@ -3782,6 +3785,7 @@ "src/tools/queries/maritime.js", "src/tools/queries/share.js", "src/data/idSwitches.js", + "src/data/vesselTypes.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/sources/gbfsStations.js", diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js index 311a238c6..fbf209040 100644 --- a/src/data/vesselLabels.js +++ b/src/data/vesselLabels.js @@ -5,6 +5,9 @@ */ 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. */ @@ -43,31 +46,6 @@ const TYPE_STYLES = [ ]; 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 // and tests match on them. const TYPE_DISPLAY_KEYS = Object.freeze({ @@ -104,21 +82,6 @@ export function displayVesselType(type) { 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. */ export function vesselTypeCss(type) { return styleForType(type).css; diff --git a/src/data/vesselTypes.js b/src/data/vesselTypes.js new file mode 100644 index 000000000..12886795c --- /dev/null +++ b/src/data/vesselTypes.js @@ -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'; +} diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index b5bdc4afe..5798a0936 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -20,6 +20,7 @@ import { numericSummary, thinEvenly, } from '../results.js'; +import { normalizeVesselType } from '../../data/vesselTypes.js'; const MMSI_SCHEMA = Object.freeze({ type: 'string', @@ -32,11 +33,17 @@ const ALL_VESSELS = 1_000_000; const round = (value, digits) => Number.isFinite(value) ? Number(value.toFixed(digits)) : 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 -// '0', so it is reported as unknown rather than counted as a type. -const vesselType = (value) => { - const type = text(value); - return type === '0' ? null : type; +// AIS sends ship types as ITU-R M.1371 codes ("70" to "79" are all cargo), so +// a code reads as its family ("CARGO"), which is what group_by counts and the +// type filter matches; text types pass through. Type 0 is "not available" and +// reads as unknown. +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) { @@ -46,7 +53,7 @@ function vesselRow(record, from) { name: text(record.name), imo: text(record.imo), type: vesselType(record.type), - destination: text(record.destination), + destination: vesselDestination(record.destination), lat: round(record.latitude, 5), lon: round(record.longitude, 5), speed_mps: round(record.speedMps, 1), @@ -134,7 +141,8 @@ export const vesselsInArea = defineTool({ .filter( (record) => 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)) .sort((a, b) => a.distance_km - b.distance_km); diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index f1d09bccb..dfa1fd761 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -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 () => { const catalog = composeCatalog({ tools: coreTools,