mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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]>
269 lines
8.8 KiB
JavaScript
269 lines
8.8 KiB
JavaScript
/** Maritime queries over the live vessel source (src/sources/live). */
|
|
|
|
import { suggestView } from '../views.js';
|
|
import { defineTool, ToolError } from '../catalog.js';
|
|
import {
|
|
AREA_SCHEMA,
|
|
areaCenter,
|
|
areaContains,
|
|
areaRadiusKm,
|
|
distanceKm,
|
|
resolveArea,
|
|
} from '../area.js';
|
|
import {
|
|
LIMIT_SCHEMA,
|
|
breakdown,
|
|
breakdownPhrase,
|
|
capRows,
|
|
countNoun,
|
|
isoTime,
|
|
numericSummary,
|
|
thinEvenly,
|
|
} from '../results.js';
|
|
import { normalizeVesselType } from '../../data/vesselTypes.js';
|
|
|
|
const MMSI_SCHEMA = Object.freeze({
|
|
type: 'string',
|
|
pattern: '^\\d{5,10}$',
|
|
description: 'Maritime Mobile Service Identity, such as "366999712".',
|
|
});
|
|
const MAX_TRACK_POINTS = 200;
|
|
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 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) {
|
|
const point = { lat: record.latitude, lon: record.longitude };
|
|
return {
|
|
mmsi: record.id,
|
|
name: text(record.name),
|
|
imo: text(record.imo),
|
|
type: vesselType(record.type),
|
|
destination: vesselDestination(record.destination),
|
|
lat: round(record.latitude, 5),
|
|
lon: round(record.longitude, 5),
|
|
speed_mps: round(record.speedMps, 1),
|
|
course_deg: round(record.courseDeg, 0),
|
|
heading_deg: round(record.headingDeg, 0),
|
|
observed_at: isoTime(record.observedAtMs),
|
|
...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Every vessel the source retains, or with `area`, every vessel around it.
|
|
* The source's default is the newest few thousand, which can leave out a
|
|
* vessel the server still holds; asking for more than any server keeps lets
|
|
* the server return all of them. The area lets a server answer for that
|
|
* place alone; callers still keep only the vessels inside it.
|
|
*/
|
|
function readVessels(services, signal, area) {
|
|
const center = area ? areaCenter(area) : null;
|
|
return services.vessels.getSnapshot(
|
|
{
|
|
maxRows: ALL_VESSELS,
|
|
...(center
|
|
? {
|
|
area: {
|
|
lat: center.lat,
|
|
lon: center.lon,
|
|
radiusKm: areaRadiusKm(area),
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
{ signal },
|
|
);
|
|
}
|
|
|
|
function snapshotInfo(snapshot) {
|
|
return {
|
|
source: snapshot.source ?? null,
|
|
coverage: snapshot.coverage ?? null,
|
|
observed_at: isoTime(snapshot.observedAtMs),
|
|
freshness: snapshot.freshness ?? 'unknown',
|
|
};
|
|
}
|
|
|
|
export const vesselsInArea = defineTool({
|
|
name: 'vessels_in_area',
|
|
title: 'Vessels in an area',
|
|
description:
|
|
'Ships currently reported by AIS inside an area, nearest the center ' +
|
|
'first, optionally filtered by vessel type. group_by counts every matched ' +
|
|
'ship by type or destination, and stats summarizes their speed; both ' +
|
|
'cover all matches, not just the rows returned.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
area: AREA_SCHEMA,
|
|
type: {
|
|
type: 'string',
|
|
minLength: 1,
|
|
maxLength: 40,
|
|
description: 'Vessel type to match, such as "tanker" or "cargo".',
|
|
},
|
|
group_by: {
|
|
type: 'string',
|
|
enum: ['type', 'destination'],
|
|
description: 'Count matched vessels by this field, largest first.',
|
|
},
|
|
stats: {
|
|
type: 'boolean',
|
|
description: 'Summarize speed over matched vessels.',
|
|
},
|
|
limit: LIMIT_SCHEMA,
|
|
},
|
|
required: ['area'],
|
|
additionalProperties: false,
|
|
},
|
|
requires: ['vessels'],
|
|
async run(args, { services, signal }) {
|
|
const area = await resolveArea(args.area, { services, signal });
|
|
const center = areaCenter(area);
|
|
const snapshot = await readVessels(services, signal, area);
|
|
const wanted = args.type?.toLowerCase();
|
|
const rows = snapshot.records
|
|
.filter(
|
|
(record) =>
|
|
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
|
(!wanted ||
|
|
(vesselType(record.type) ?? '').toLowerCase().includes(wanted)),
|
|
)
|
|
.map((record) => vesselRow(record, center))
|
|
.sort((a, b) => a.distance_km - b.distance_km);
|
|
const kind = args.type ? `${args.type} vessel` : 'vessel';
|
|
const groups = args.group_by ? breakdown(rows, args.group_by) : null;
|
|
return {
|
|
summary:
|
|
`${countNoun(rows.length, kind)} in ${area.label}` +
|
|
(groups ? breakdownPhrase(groups, args.group_by) : '') +
|
|
(snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'),
|
|
data: {
|
|
view: suggestView(services, { area, layers: ['ais-live-vessels'] }),
|
|
...capRows(rows, args.limit),
|
|
...(groups ? { breakdown: groups } : {}),
|
|
...(args.stats
|
|
? { stats: { speed_mps: numericSummary(rows, 'speed_mps') } }
|
|
: {}),
|
|
...snapshotInfo(snapshot),
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
export const findVessel = defineTool({
|
|
name: 'find_vessel',
|
|
title: 'Find a vessel',
|
|
description:
|
|
'Find ships currently reported anywhere by MMSI, IMO number or name. ' +
|
|
'Names match when they contain the given text.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
mmsi: MMSI_SCHEMA,
|
|
imo: { type: 'string', pattern: '^\\d{7}$' },
|
|
name: { type: 'string', minLength: 2, maxLength: 60 },
|
|
limit: LIMIT_SCHEMA,
|
|
},
|
|
additionalProperties: false,
|
|
},
|
|
requires: ['vessels'],
|
|
async run(args, { services, signal }) {
|
|
const given = ['mmsi', 'imo', 'name'].filter((key) => args[key] != null);
|
|
if (given.length !== 1)
|
|
throw new ToolError(
|
|
'invalid_arguments',
|
|
'Give exactly one of mmsi, imo or name',
|
|
);
|
|
const [key] = given;
|
|
const wanted = args[key].trim().toUpperCase();
|
|
const matches = {
|
|
mmsi: (record) => record.id === wanted,
|
|
imo: (record) => String(record.imo).replace(/^IMO/i, '') === wanted,
|
|
name: (record) => String(record.name).toUpperCase().includes(wanted),
|
|
}[key];
|
|
const snapshot = await readVessels(services, signal);
|
|
const rows = snapshot.records
|
|
.filter(matches)
|
|
.map((record) => vesselRow(record));
|
|
return {
|
|
summary: rows.length
|
|
? `Found ${countNoun(rows.length, 'vessel')} with ${key} ${args[key]}.`
|
|
: `No vessel with ${key} ${args[key]} is currently reported.`,
|
|
data: {
|
|
// Ships cannot be followed from a link; one match is framed closely.
|
|
view:
|
|
rows.length === 1
|
|
? suggestView(services, {
|
|
point: { lat: rows[0].lat, lon: rows[0].lon },
|
|
layers: ['ais-live-vessels'],
|
|
altitudeM: 5_000,
|
|
})
|
|
: null,
|
|
...capRows(rows, args.limit),
|
|
...snapshotInfo(snapshot),
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
export const getVesselTrack = defineTool({
|
|
name: 'get_vessel_track',
|
|
title: 'Vessel track',
|
|
description:
|
|
'Recent positions of one ship by MMSI, oldest first. Long tracks are ' +
|
|
`evenly thinned to at most ${MAX_TRACK_POINTS} points.`,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: { mmsi: MMSI_SCHEMA },
|
|
required: ['mmsi'],
|
|
additionalProperties: false,
|
|
},
|
|
requires: ['vessels'],
|
|
async run(args, { services, signal }) {
|
|
const track = await services.vessels.getTrack(args.mmsi, { signal });
|
|
const points = track.records
|
|
.filter((point) => Number.isFinite(point.observedAtMs))
|
|
.sort((a, b) => a.observedAtMs - b.observedAtMs);
|
|
const kept = thinEvenly(points, MAX_TRACK_POINTS);
|
|
return {
|
|
summary: points.length
|
|
? `${countNoun(points.length, 'position')} for ${args.mmsi} from ${isoTime(points[0].observedAtMs)} to ${isoTime(points.at(-1).observedAtMs)}.`
|
|
: `No recent track is available for ${args.mmsi}.`,
|
|
data: {
|
|
view: kept.length
|
|
? suggestView(services, {
|
|
point: { lat: kept.at(-1).latitude, lon: kept.at(-1).longitude },
|
|
layers: ['ais-live-vessels'],
|
|
altitudeM: 20_000,
|
|
})
|
|
: null,
|
|
mmsi: args.mmsi,
|
|
total: points.length,
|
|
returned: kept.length,
|
|
thinned: kept.length < points.length,
|
|
points: kept.map((point) => ({
|
|
time: isoTime(point.observedAtMs),
|
|
lat: round(point.latitude, 5),
|
|
lon: round(point.longitude, 5),
|
|
})),
|
|
},
|
|
};
|
|
},
|
|
});
|