diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a5b85c825..b52605c99 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/src/cockpitRadar.js b/src/cockpitRadar.js index 5cf1f90f7..502e87529 100644 --- a/src/cockpitRadar.js +++ b/src/cockpitRadar.js @@ -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} 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 e18403972..98ad47e50 100644 --- a/src/cockpitRadar.test.mjs +++ b/src/cockpitRadar.test.mjs @@ -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, diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index 4fd53c35c..8f1f874e0 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -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, + }; + }); }, /** diff --git a/src/layers/vessels/radarContacts.test.mjs b/src/layers/vessels/radarContacts.test.mjs index 9366e7e3a..d0797aae1 100644 --- a/src/layers/vessels/radarContacts.test.mjs +++ b/src/layers/vessels/radarContacts.test.mjs @@ -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), []); diff --git a/src/ui/cockpitBridge.test.mjs b/src/ui/cockpitBridge.test.mjs index b0302b50e..1273b5617 100644 --- a/src/ui/cockpitBridge.test.mjs +++ b/src/ui/cockpitBridge.test.mjs @@ -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); diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index 2e114dd47..fd076663c 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -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, diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index 1d32a4cd4..9e6109811 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -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, }, diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 38a24ef8a..f300fe1c2 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -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;