fix(cockpit): the bridge radar matches the view out of the window

The radar centred on the ship's last AIS fix while the camera rode that fix
dead-reckoned up to 180 s ahead: at 20 kn about a nautical mile apart, so a
ship already passed still showed ahead. Own ship now sits where the camera
rides and each contact is dead-reckoned to the same moment; the query reaches
past the range for ships that have since moved into it.

Ships with no MMSI each get a key of their own instead of all sharing an
empty one on the scope.
This commit is contained in:
Sameh Khamis committed 2026-11-08 14:09:17 -08:00
1 parent 18c15d0352
commit 052731202d
9 files changed
+154 -29

No files matched your search

+42
View File
@@ -9,6 +9,10 @@ import {
relativeBearing,
} from './cockpitMath.js';
import { greatCircleMeters } from './geoDistance.js';
import {
deadReckonVesselFix,
VESSEL_DEAD_RECKON_MAX_S,
} from './cockpitSubjects.js';
/** Metres in one international nautical mile. */
export const METRES_PER_NM = 1852;
@@ -22,6 +26,14 @@ export const DEFAULT_RADAR_RANGE_NM = 3;
/** At most this many contacts are drawn, nearest first. */
export const RADAR_MAX_CONTACTS = 60;
/**
* How far past the range a contact's last fix can lie and still be in range
* once dead-reckoned: the projection limit at 40 kn, faster than all but a
* few high-speed craft.
*/
export const RADAR_REACH_M =
VESSEL_DEAD_RECKON_MAX_S * 40 * (METRES_PER_NM / 3600);
/** Range rings divide the range into this many equal steps. */
export const RADAR_RING_COUNT = 4;
@@ -68,6 +80,36 @@ export function radarRangeM(rangeNm) {
const round4 = (value) => Math.round(value * 1e4) / 1e4;
/**
* Advance each contact's last fix along its course over ground to `nowMs`,
* as the bridge camera advances its own ship, so own ship and contacts are
* drawn at the same moment. A contact with no direction stays on its fix.
* @param {Array<object>} contacts Contacts with the `vesselCockpitInfo`
* motion fields (`motionCourse`, `velocityMps`, `fixEpochMs`,
* `receivedAtMs`, `feedEpochMs`).
* @param {number} nowMs Client clock, ms.
* @returns {Array<object>} The contacts with dead-reckoned positions.
*/
export function deadReckonRadarContacts(contacts, nowMs) {
if (!Array.isArray(contacts)) return [];
return contacts.map((contact) => {
if (!Number.isFinite(contact?.motionCourse)) return contact;
const fix = deadReckonVesselFix(
{
latitude: contact.latitude,
longitude: contact.longitude,
courseDeg: contact.motionCourse,
speedMps: contact.velocityMps,
fixEpochMs: contact.fixEpochMs,
receivedAtMs: contact.receivedAtMs,
feedEpochMs: contact.feedEpochMs,
},
nowMs,
);
return { ...contact, latitude: fix.latitude, longitude: fix.longitude };
});
}
/**
* Project vessel contacts onto a heading-up radar.
*
+20
View File
@@ -7,6 +7,7 @@ import {
METRES_PER_NM,
RADAR_MAX_CONTACTS,
RADAR_RANGES_NM,
deadReckonRadarContacts,
nextRadarRange,
projectRadarContacts,
radarPicture,
@@ -96,6 +97,25 @@ test('course is drawn relative to the bow and stays null when unknown', () => {
assert.equal(drifting.relativeCourseDeg, null);
});
test('contacts are dead-reckoned along course over ground to now', () => {
const nowMs = 1_800_000_000_000;
const fixEpochMs = nowMs - 120_000;
// 12 kn due north, fix received a minute ago: ~370 m (0.2 NM) on.
const moving = contact('moving', 1, 0, {
motionCourse: 0,
velocityMps: 12 * 0.514444,
fixEpochMs,
feedEpochMs: fixEpochMs,
receivedAtMs: nowMs - 60_000,
});
const adrift = contact('adrift', 1, 0, { motionCourse: null });
const [ahead, still] = deadReckonRadarContacts([moving, adrift], nowMs);
const movedNm = (ahead.latitude - moving.latitude) / NM_LAT;
assert.ok(Math.abs(movedNm - 0.2) < 0.005, `moved ${movedNm} NM`);
assert.ok(Math.abs(ahead.longitude - moving.longitude) < 1e-9);
assert.equal(still, adrift, 'no direction, no move');
});
test('contacts beyond the range are left out', () => {
const contacts = projectRadarContacts(
OWN,
+31 -18
View File
@@ -360,8 +360,12 @@ export function createQueries({
* @param {number} [maxCount=60] Maximum entries to return.
* @returns {Array<{ id: string, name: string, latitude: number,
* longitude: number, course: number|null, heading: number|null,
* type: string, color: string, stale: boolean }>} Empty while the
* layer is off.
* type: string, color: string, stale: boolean,
* motionCourse: number|null, velocityMps: number|null,
* fixEpochMs: number|null, receivedAtMs: number|null,
* feedEpochMs: number|null }>} Empty while the layer is off. The
* motion fields are `vesselCockpitInfo`'s, for dead reckoning; a
* ship with no MMSI has no motion fields and an `unkeyed:` id.
*/
getRadarContacts(lat, lon, rangeM, maxCount = 60) {
const records = state.records.all;
@@ -379,27 +383,36 @@ export function createQueries({
};
const entries = [];
for (const record of records) {
if (subject && String(record.mmsi) === subject) continue;
records.forEach((record, index) => {
if (subject && String(record.mmsi) === subject) return;
const distanceM = greatCircleMeters(center, {
lat: record.lat,
lon: record.lon,
});
if (!(distanceM <= rangeM)) continue;
entries.push({ record, distanceM });
}
if (!(distanceM <= rangeM)) return;
entries.push({ record, index, distanceM });
});
entries.sort((a, b) => a.distanceM - b.distanceM);
return entries.slice(0, cap).map(({ record }) => ({
id: String(record.mmsi),
name: String(record.name || record.mmsi),
latitude: record.lat,
longitude: record.lon,
course: finite(record.course),
heading: finite(record.heading),
type: String(record.type || ''),
color: vesselTypeCss(record.type),
stale: (record.missedRefreshes || 0) > 0,
}));
return entries.slice(0, cap).map(({ record, index }) => {
const motion = vesselCockpitInfo(record);
return {
// A ship with no MMSI still needs a key of its own on the scope.
id: record.mmsi ? String(record.mmsi) : `unkeyed:${index}`,
name: String(record.name || record.mmsi || ''),
latitude: record.lat,
longitude: record.lon,
course: finite(record.course),
heading: finite(record.heading),
type: String(record.type || ''),
color: vesselTypeCss(record.type),
stale: (record.missedRefreshes || 0) > 0,
motionCourse: motion?.motionCourse ?? null,
velocityMps: motion?.velocityMps ?? null,
fixEpochMs: motion?.fixEpochMs ?? null,
receivedAtMs: motion?.receivedAtMs ?? null,
feedEpochMs: motion?.feedEpochMs ?? null,
};
});
},
/**
+16
View File
@@ -91,6 +91,11 @@ test('radar contacts are within range, nearest first and capped', () => {
type: '70',
color: vesselTypeCss('70'),
stale: false,
motionCourse: 45,
velocityMps: 10 * 0.514444,
fixEpochMs: null,
receivedAtMs: null,
feedEpochMs: null,
});
assert.equal(contacts[1].course, null);
assert.equal(contacts[1].heading, null);
@@ -112,6 +117,17 @@ test('the ship whose bridge the Cockpit rides is not its own contact', () => {
assert.equal(layer.getRadarContacts(54.5, 10.2, 6 * 1852).length, 2);
});
test('ships with no MMSI each keep a key of their own', () => {
const layer = layerWith([
record('', 1, { name: 'A' }),
record('', 2, { name: 'B' }),
]);
const ids = layer.getRadarContacts(54.5, 10.2, 6 * 1852).map((c) => c.id);
assert.equal(ids.length, 2);
assert.equal(new Set(ids).size, 2, ids.join(', '));
assert.ok(ids.every((id) => id.startsWith('unkeyed:')));
});
test('a disabled layer has no radar contacts', () => {
const layer = layerWith([record('1', 1)], { enabled: false });
assert.deepEqual(layer.getRadarContacts(54.5, 10.2, 6 * 1852), []);
+19 -2
View File
@@ -13,6 +13,7 @@ import ContextView from './views/ContextView.vue';
import * as Cesium from 'cesium';
import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js';
import { VesselRecords } from '../layers/vessels/records.js';
import { RADAR_REACH_M } from '../cockpitRadar.js';
const RECORD = Object.freeze({
mmsi: '211000000',
@@ -327,7 +328,8 @@ test('the bridge radar shows nearby ships without its own and steps its range',
assert.ok(neighbour.x < -0.3 && Math.abs(neighbour.y) < 0.02);
assert.equal(neighbour.relativeCourseDeg, -180);
assert.equal(radar.ownRelativeCourseDeg, 5, 'COG 095 from heading 090');
assert.equal(vessels.radarQueries.at(-1).rangeM, 3 * 1852);
// Past the range by the dead-reckoning reach, for ships moving into it.
assert.equal(vessels.radarQueries.at(-1).rangeM, 3 * 1852 + RADAR_REACH_M);
assert.equal($('#cockpit-radar').hidden, false);
assert.equal($('#cockpit-radar-range').textContent.trim(), '3 NM');
@@ -337,7 +339,7 @@ test('the bridge radar shows nearby ships without its own and steps its range',
$('#cockpit-radar-range').click();
await settle();
assert.equal(store.radar.rangeNm, 6);
assert.equal(vessels.radarQueries.at(-1).rangeM, 6 * 1852);
assert.equal(vessels.radarQueries.at(-1).rangeM, 6 * 1852 + RADAR_REACH_M);
assert.equal($('#cockpit-radar-range').textContent.trim(), '6 NM');
assert.match(
$('#cockpit-radar-range').getAttribute('aria-label'),
@@ -1075,6 +1077,21 @@ test('the water is read where the ship is now, not at its last report', async (t
assert.ok(eastM > 300 && eastM < 450, `probed ${eastM.toFixed(0)} m east`);
});
test('the radar centres where the camera rides, not at the last report', async (t) => {
const clock = manualClock(t, START_MS);
// Reported a minute ago at 12 kn on course 095: since then ~370 m east.
const record = { ...RECORD, lastPositionEpoch: (START_MS - 60_000) / 1000 };
const ship = fixture(t, { record });
ship.cockpit.enter();
await settle();
frames(ship.cockpit, clock, ship.shots, 20);
await settle();
const query = ship.vessels.radarQueries.at(-1);
const eastM =
(query.lon - RECORD.lon) * 111_320 * Math.cos((RECORD.lat * Math.PI) / 180);
assert.ok(eastM > 300 && eastM < 450, `centred ${eastM.toFixed(0)} m east`);
});
test('the radar look line turns the short way across astern', async (t) => {
const clock = manualClock(t, START_MS);
const { $, store } = await bridgeAt(t, clock);
+2
View File
@@ -235,6 +235,8 @@ function updateVessel(nowMs) {
},
Date.now(),
);
// The radar centres here too, so it agrees with the view out the window.
this.vesselFix = fix;
const target = Cesium.Cartesian3.fromDegrees(
fix.longitude,
fix.latitude,
+18 -8
View File
@@ -37,6 +37,8 @@ import {
import { lookIsOffset, lookReadoutParts, wrapLookYaw } from '../cockpitLook.js';
import {
RADAR_MAX_CONTACTS,
RADAR_REACH_M,
deadReckonRadarContacts,
radarPicture,
radarRangeM,
radarRangeNm,
@@ -342,7 +344,9 @@ function updateVesselHud(info, nowMs, forceContext) {
/**
* Publish the bridge radar: nearby AIS vessels drawn heading-up from the
* camera's current heading, within the chosen range.
* camera's current heading, within the chosen range. Own ship sits where
* the camera rides (its fix dead-reckoned), and every contact is
* dead-reckoned to the same moment, so the picture matches the window.
* @param {object} info The vessel subject (`readVesselInfo`).
*/
export function updateRadar(info) {
@@ -352,16 +356,22 @@ export function updateRadar(info) {
return;
}
const rangeNm = radarRangeNm(this.radarRangeNm);
const contacts = layer.getRadarContacts(
info.latitude,
info.longitude,
radarRangeM(rangeNm),
RADAR_MAX_CONTACTS,
const own = this.vesselFix ?? info;
// Ask past the range for ships that have since moved into it; the picture
// trims them to the range once they are dead-reckoned.
const contacts = deadReckonRadarContacts(
layer.getRadarContacts(
own.latitude,
own.longitude,
radarRangeM(rangeNm) + RADAR_REACH_M,
RADAR_MAX_CONTACTS * 4,
),
Date.now(),
);
const picture = radarPicture(
{
latitude: info.latitude,
longitude: info.longitude,
latitude: own.latitude,
longitude: own.longitude,
headingDeg: normalizeHeading(this.heading ?? info.track ?? 0),
courseDeg: info.course,
},
+2
View File
@@ -218,6 +218,7 @@ export function enter({ kind = null } = {}) {
this.subjectKind = subjectKind;
// Each session reads its own water: never a height left from the last.
this.vesselWaterline = null;
this.vesselFix = null;
this.subjectId = vessel ? info.mmsi : null;
this.trackedEntity = entity;
if (entity) {
@@ -329,6 +330,7 @@ export function exit({ restoreTracking = true } = {}) {
this.active = false;
this.subjectKind = null;
this.vesselWaterline = null;
this.vesselFix = null;
this.subjectId = null;
this.services.releaseContinuousRender('cockpit');
this.trackedEntity = null;