mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 04:44:01 +00:00
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:
1 parent
0ba7ba6512
commit
f96a89a9ef
7 files changed
+27
-114
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user