mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 04:44:01 +00:00
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:
1 parent
18c15d0352
commit
052731202d
9 files changed
+154
-29
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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), []);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user