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

+4 -1
View File
@@ -1590,7 +1590,10 @@ above it keeps the ship's true heading; a ring with a centre dot marks the bow
from the caret. The bridge radar
shows nearby AIS ships heading-up and opens at 3 NM
(`DEFAULT_RADAR_RANGE_NM` in `src/cockpitRadar.js`); its range button steps
3, 6, 12 and 24 NM and the chosen range is kept across bridges. Contact
3, 6, 12 and 24 NM and the chosen range is kept across bridges. Own ship
sits where the camera rides, and every contact's last fix is dead-reckoned
along its course over ground to the same moment (`deadReckonRadarContacts`),
so the scope matches the view out of the window. Contact
Previous/Next and Live Signals aircraft handoffs are refused on the bridge and
the Contact card stays hidden. Exit returns the camera to the vessel's map view
through the layer's own focus. Tracking an aircraft or losing the vessel
+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;