fix(cockpit): radar contacts stay where the vessel layer draws them

The vessel layer draws other ships at their last AIS fix and does not
advance them, so dead-reckoning radar contacts moved the mismatch from own
ship to everyone else: a 15 kn ferry reported 90 s ago showed ~700 m ahead
of the ship out of the window. Own ship stays centred where the camera
rides; contacts go back to their last fixes, and the query drops the extra
reach and candidates (which could also cut the nearest ship in a crowd).
This commit is contained in:
Sameh Khamis committed 2026-11-08 14:09:17 -08:00
1 parent 0ba7ba6512
commit f96a89a9ef
7 files changed
+27 -114

No files matched your search

+3 -3
View File
@@ -1591,9 +1591,9 @@ 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. 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
sits where the camera rides (its fix dead-reckoned) and contacts at their
last fixes, where the vessel layer draws them, 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,10 +9,6 @@ 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;
@@ -26,14 +22,6 @@ 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;
@@ -80,36 +68,6 @@ 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,7 +7,6 @@ import {
METRES_PER_NM,
RADAR_MAX_CONTACTS,
RADAR_RANGES_NM,
deadReckonRadarContacts,
nextRadarRange,
projectRadarContacts,
radarPicture,
@@ -97,25 +96,6 @@ 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,
+14 -26
View File
@@ -360,12 +360,8 @@ 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,
* 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.
* type: string, color: string, stale: boolean }>} Empty while the
* layer is off. A ship with no MMSI has an `unkeyed:` id.
*/
getRadarContacts(lat, lon, rangeM, maxCount = 60) {
const records = state.records.all;
@@ -393,26 +389,18 @@ export function createQueries({
entries.push({ record, index, distanceM });
});
entries.sort((a, b) => a.distanceM - b.distanceM);
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,
};
});
return entries.slice(0, cap).map(({ record, index }) => ({
// 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,
}));
},
/**
@@ -92,11 +92,6 @@ 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);
+2 -4
View File
@@ -13,7 +13,6 @@ 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',
@@ -328,8 +327,7 @@ 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');
// 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(vessels.radarQueries.at(-1).rangeM, 3 * 1852);
assert.equal($('#cockpit-radar').hidden, false);
assert.equal($('#cockpit-radar-range').textContent.trim(), '3 NM');
@@ -339,7 +337,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 + RADAR_REACH_M);
assert.equal(vessels.radarQueries.at(-1).rangeM, 6 * 1852);
assert.equal($('#cockpit-radar-range').textContent.trim(), '6 NM');
assert.match(
$('#cockpit-radar-range').getAttribute('aria-label'),
+8 -14
View File
@@ -37,8 +37,6 @@ import {
import { lookIsOffset, lookReadoutParts, wrapLookYaw } from '../cockpitLook.js';
import {
RADAR_MAX_CONTACTS,
RADAR_REACH_M,
deadReckonRadarContacts,
radarPicture,
radarRangeM,
radarRangeNm,
@@ -345,8 +343,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. 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.
* the camera rides (its fix dead-reckoned); contacts sit at their last
* fixes, where the vessel layer draws them, so the scope matches the
* window.
* @param {object} info The vessel subject (`readVesselInfo`).
*/
export function updateRadar(info) {
@@ -357,16 +356,11 @@ export function updateRadar(info) {
}
const rangeNm = radarRangeNm(this.radarRangeNm);
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 contacts = layer.getRadarContacts(
own.latitude,
own.longitude,
radarRangeM(rangeNm),
RADAR_MAX_CONTACTS,
);
const picture = radarPicture(
{