From f96a89a9ef6731c954866b88d5701e34c517b7c7 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 8 Nov 2026 13:47:37 -0800 Subject: [PATCH] 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). --- docs/CURRENT-STATE.md | 6 ++-- src/cockpitRadar.js | 42 ----------------------- src/cockpitRadar.test.mjs | 20 ----------- src/layers/vessels/queries.js | 40 ++++++++------------- src/layers/vessels/radarContacts.test.mjs | 5 --- src/ui/cockpitBridge.test.mjs | 6 ++-- src/ui/cockpitInstruments.js | 22 +++++------- 7 files changed, 27 insertions(+), 114 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b52605c99..372fd4dc9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/src/cockpitRadar.js b/src/cockpitRadar.js index 502e87529..5cf1f90f7 100644 --- a/src/cockpitRadar.js +++ b/src/cockpitRadar.js @@ -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} contacts Contacts with the `vesselCockpitInfo` - * motion fields (`motionCourse`, `velocityMps`, `fixEpochMs`, - * `receivedAtMs`, `feedEpochMs`). - * @param {number} nowMs Client clock, ms. - * @returns {Array} 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. * diff --git a/src/cockpitRadar.test.mjs b/src/cockpitRadar.test.mjs index 98ad47e50..e18403972 100644 --- a/src/cockpitRadar.test.mjs +++ b/src/cockpitRadar.test.mjs @@ -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, diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index 710e7cccd..8cc32927e 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -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, + })); }, /** diff --git a/src/layers/vessels/radarContacts.test.mjs b/src/layers/vessels/radarContacts.test.mjs index 86855b512..d89d7cf5d 100644 --- a/src/layers/vessels/radarContacts.test.mjs +++ b/src/layers/vessels/radarContacts.test.mjs @@ -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); diff --git a/src/ui/cockpitBridge.test.mjs b/src/ui/cockpitBridge.test.mjs index 05ece6b89..89bb54b2b 100644 --- a/src/ui/cockpitBridge.test.mjs +++ b/src/ui/cockpitBridge.test.mjs @@ -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'), diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index 413727f3b..b68279643 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -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( {