fix(tools): wrap whole-degree headings at 360 to 0

The query tools rounded courses, headings, bearings and azimuths with
Number(value.toFixed(0)) and no wrap, so a direction of 359.5 degrees or
more came back as 360: find_vessel with course 359.7 and heading 359.6
returned course_deg 360 and heading_deg 360.

Round them through the shared roundHeadingDegrees helper the layers and
UI already use (src/data/headingDegrees.js), which wraps into 0 to 359:
course_deg and heading_deg (vessels), course_deg (aircraft), bearing_deg
(transit), heading_deg (cameras), and rise_azimuth_deg and azimuth_deg
(satellites). Other rounding is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
quanticsoul4772andClaude Opus 5.5 committed 2026-11-03 06:32:07 -08:00
1 parent f3adccd01a
commit ae55963a0e
11 files changed
+97 -8

No files matched your search

+4 -1
View File
@@ -204,7 +204,10 @@ perimeters, terrain height, military installations and map features, plus a
combined situation brief, military awareness around a point, the app's heads-up display caption, and a link that
opens the app over an area. Tools reuse the layers' portable source factories, take a
shared `area` argument (place name, bounding box, or point and radius) and cap
lists at 25 rows by default. `aircraft_in_area` and `vessels_in_area` can also
lists at 25 rows by default. Vessel `course_deg` and `heading_deg`, aircraft
`course_deg`, transit `bearing_deg`, camera `heading_deg` and satellite
`rise_azimuth_deg` and `azimuth_deg` are whole degrees from 0 to 359; a
direction of 359.5° or more reads 0 rather than 360. `aircraft_in_area` and `vessels_in_area` can also
count every match by a field (`group_by`: airline, read from the ICAO
designator in the callsign, or operator, country or type for aircraft; type or
destination for ships; the ten largest groups, then other and unknown counts;
+2 -1
View File
@@ -19,6 +19,7 @@ import {
numericSummary,
thinEvenly,
} from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const ICAO24_SCHEMA = Object.freeze({
type: 'string',
@@ -75,7 +76,7 @@ function aircraftRow(record, from) {
altitude_m: round(record.baroAltitudeM ?? record.ellipsoidAltitudeM, 0),
on_ground: record.onGround === true,
speed_mps: round(record.speedMps, 1),
course_deg: round(record.courseDeg, 0),
course_deg: roundHeadingDegrees(record.courseDeg),
vertical_rate_mps: round(record.verticalRateMps, 1),
type_code: record.typeCode || null,
registration: record.registration || null,
+13
View File
@@ -146,6 +146,19 @@ test('the military feed is used on request and reported when missing', async ()
);
});
test('an aircraft course that rounds to 360 reads as 0', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
aircraft: feed([
record('ddd444', 37.6, -122.3, { callsign: 'N360', courseDeg: 359.6 }),
]),
},
});
const result = await catalog.call('find_aircraft', { callsign: 'N360' });
assert.equal(result.data.rows[0].course_deg, 0);
});
test('aircraft are found by exactly one identifier across feeds', async () => {
const catalog = composeCatalog({
tools: coreTools,
+3 -2
View File
@@ -20,6 +20,7 @@ import {
numericSummary,
thinEvenly,
} from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
import { normalizeVesselType } from '../../data/vesselTypes.js';
const MMSI_SCHEMA = Object.freeze({
@@ -57,8 +58,8 @@ function vesselRow(record, from) {
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),
course_deg: roundHeadingDegrees(record.courseDeg),
heading_deg: roundHeadingDegrees(record.headingDeg),
observed_at: isoTime(record.observedAtMs),
...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}),
};
+19
View File
@@ -351,6 +351,25 @@ test('find_vessel answers carry normalized types and destinations', async () =>
);
});
test('a vessel course or heading that rounds to 360 reads as 0', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: {
vessels: vessels({
records: [
vessel('366000003', 37.78, -122.4, {
courseDeg: 359.7,
headingDeg: 359.6,
}),
],
}),
},
});
const [row] = (await catalog.call('find_vessel', { mmsi: '366000003' })).data
.rows;
assert.deepEqual([row.course_deg, row.heading_deg], [0, 0]);
});
test('a destination with no letters counts as unknown', async () => {
const catalog = composeCatalog({
tools: coreTools,
+2 -1
View File
@@ -10,6 +10,7 @@ import {
resolveArea,
} from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, toBase64 } from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const FRAME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
const MAX_FRAME_BYTES = 3 * 1024 * 1024;
@@ -85,7 +86,7 @@ export const findCctvCameras = defineTool({
provider: text(camera.provider),
lat: round(camera.lat, 6),
lon: round(camera.lon, 6),
heading_deg: round(camera.headingDeg, 0),
heading_deg: roundHeadingDegrees(camera.headingDeg),
feed_type: text(camera.feedType),
credit: text(camera.credit),
distance_km: round(distanceKm(center, camera), 2),
+14
View File
@@ -77,6 +77,20 @@ test('cameras in an area are listed nearest first', async () => {
assert.equal(result.data.rows[1].credit, null);
});
test('a camera heading that rounds to 360 reads as 0', async () => {
const source = {
async getCatalog() {
return { sources: [{ ...cameras[0], headingDeg: 359.6 }] };
},
};
const catalog = composeCatalog({
tools: coreTools,
services: { cctv: source },
});
const result = await catalog.call('find_cctv_cameras', { area: downtown });
assert.equal(result.data.rows[0].heading_deg, 0);
});
test('a snapshot returns the frame as an image with its credit', async () => {
const source = cctv();
const catalog = composeCatalog({
+2 -1
View File
@@ -19,6 +19,7 @@ import {
resolveArea,
} from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const MAX_SYSTEMS = 3;
@@ -244,7 +245,7 @@ export const getTransitVehicles = defineTool({
trip_id: vehicle.tripId ?? null,
lat: vehicle.lat,
lon: vehicle.lon,
bearing_deg: round(vehicle.bearing, 0),
bearing_deg: roundHeadingDegrees(vehicle.bearing),
speed_mps: round(vehicle.speedMps, 1),
status: vehicle.status ?? null,
occupancy: vehicle.occupancy ?? null,
+17
View File
@@ -311,6 +311,23 @@ test('transit vehicles come from nearby feeds and can be limited to a route', as
assert.equal(rapid.data.rows[0].id, 'v2');
});
test('a transit bearing that rounds to 360 reads as 0', async () => {
const north = [{ ...vehicles[0], bearing: 359.6 }];
const catalog = composeCatalog({
tools: coreTools,
services: {
transit: transit({
ok: true,
status: 200,
json: async () => ({ vehicles: north }),
}),
clock,
},
});
const result = await catalog.call('get_transit_vehicles', { area: downtown });
assert.equal(result.data.rows[0].bearing_deg, 0);
});
test('transit reports uncovered areas and unavailable feeds', async () => {
const catalog = composeCatalog({
tools: coreTools,
+3 -2
View File
@@ -10,6 +10,7 @@ import { parseTleText, tleCatalogNumber } from '../../sources/tle.js';
import { POINT_SCHEMA, resolvePoint } from '../area.js';
import { defineTool, ToolError } from '../catalog.js';
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const text = (value) => (typeof value === 'string' && value ? value : null);
@@ -241,7 +242,7 @@ export const nextSatellitePass = defineTool({
peak: isoTime(pass.maxElevMs),
set: isoTime(pass.setMs),
max_elevation_deg: round(pass.maxElevDeg, 1),
rise_azimuth_deg: round(pass.riseAzDeg, 0),
rise_azimuth_deg: roundHeadingDegrees(pass.riseAzDeg),
rise_direction: compass(pass.riseAzDeg),
visible: pass.visible,
},
@@ -283,7 +284,7 @@ export const satellitesOverhead = defineTool({
name: entry.name,
norad: entry.norad,
elevation_deg: round(look.elevDeg, 1),
azimuth_deg: round(look.azDeg, 0),
azimuth_deg: roundHeadingDegrees(look.azDeg),
direction: compass(look.azDeg),
},
]
+18
View File
@@ -296,6 +296,24 @@ test('satellites overhead are those above the elevation, highest first', async (
);
});
test('satellite azimuths that round to 360 read as 0', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { satellites: satellites(), clock: CLOCK },
});
// From these points the ISS rises at 359.7° and stands at 359.8° at CLOCK.
const pass = await catalog.call('next_satellite_pass', {
location: { lat: 10, lon: 30 },
});
assert.equal(pass.data.pass.rise_azimuth_deg, 0);
assert.equal(pass.data.pass.rise_direction, 'N');
const overhead = await catalog.call('satellites_overhead', {
location: { lat: 39.25, lon: 65.75 },
});
assert.equal(overhead.data.rows[0].name, 'ISS (ZARYA)');
assert.equal(overhead.data.rows[0].azimuth_deg, 0);
});
test('stale orbit and launch data are reported', async () => {
const stale = {
readGroup: async () => ({ ok: true, status: 200, text: TLE, stale: true }),