Round every heading readout through one shared helper

The transit, ALPR, vessel, CCTV and flight/military context fixes each
wrapped 359.5° to north with an inline Math.round(x) % 360. They now call
roundHeadingDegrees in src/data/headingDegrees.js, which also wraps negative
and multi-turn bearings. normalizeHeading moves there with it, and
cockpitMath re-exports it so existing imports keep working. It now adds a
turn only to a negative remainder, so a bearing just under half a degree
keeps its precision.

The awareness, Local ADS-B, cockpit wind, cockpit heading and Street Level
readouts use the same helper. The Cockpit's TRACK ACQUIRED signal rounded
without wrapping and could read COURSE 360°; it now uses
formatHeadingReadout, which shows --- for a track that is not finite.
This commit is contained in:
Sameh Khamis committed 2026-11-01 21:41:49 -08:00
1 parent e200e46695
commit 0e90a03de7
21 files changed
+128 -36

No files matched your search

+7 -7
View File
@@ -1,10 +1,10 @@
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
import {
normalizeHeading,
roundHeadingDegrees,
} from './data/headingDegrees.js';
/** Normalize a heading into the [0, 360) range. */
export function normalizeHeading(value) {
if (!Number.isFinite(value)) return 0;
return ((value % 360) + 360) % 360;
}
export { resolveHudRailLayout } from './ui/panelRailGeometry.js';
export { normalizeHeading };
/**
* Three-digit heading readout, 000–359.
@@ -12,7 +12,7 @@ export function normalizeHeading(value) {
*/
export function formatHeadingReadout(degrees) {
if (!Number.isFinite(degrees)) return '---';
return String(Math.round(normalizeHeading(degrees)) % 360).padStart(3, '0');
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
}
/** Advance a displayed heading along the shortest arc without exceeding a slew rate. */
+1
View File
@@ -161,6 +161,7 @@ test('a heading that rounds to north reads 000, not 360', () => {
assert.equal(formatHeadingReadout(359.4), '359');
assert.equal(formatHeadingReadout(0), '000');
assert.equal(formatHeadingReadout(10.2), '010');
assert.equal(formatHeadingReadout(-0.6), '359');
assert.equal(formatHeadingReadout(Number.NaN), '---');
});
+19
View File
@@ -0,0 +1,19 @@
/** Normalize a heading into the [0, 360) range; 0 when it is not finite. */
export function normalizeHeading(value) {
if (!Number.isFinite(value)) return 0;
// Only a negative remainder takes the extra turn, so a positive bearing
// keeps its precision (0.49999999999999 must not become 360.5).
const remainder = value % 360;
const wrapped = remainder < 0 ? remainder + 360 : remainder;
return wrapped === 360 ? 0 : wrapped;
}
/**
* Whole degrees for display, 0–359. 359.5° and above rounds to north (0),
* and negative or multi-turn values wrap. Null when the value is not finite,
* so each caller keeps its own placeholder.
*/
export function roundHeadingDegrees(value) {
if (!Number.isFinite(value)) return null;
return Math.round(normalizeHeading(value)) % 360;
}
+47
View File
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeHeading, roundHeadingDegrees } from './headingDegrees.js';
test('normalizeHeading wraps into [0, 360) and reads a non-finite value as 0', () => {
assert.equal(normalizeHeading(370), 10);
assert.equal(normalizeHeading(-10), 350);
assert.equal(normalizeHeading(720), 0);
assert.equal(normalizeHeading(Number.NaN), 0);
assert.equal(normalizeHeading(Infinity), 0);
});
test('roundHeadingDegrees reads 359.5° and above as north, never 360', () => {
assert.equal(roundHeadingDegrees(359.4), 359);
assert.equal(roundHeadingDegrees(359.5), 0);
assert.equal(roundHeadingDegrees(359.6), 0);
assert.equal(roundHeadingDegrees(360), 0);
assert.equal(roundHeadingDegrees(0), 0);
assert.equal(roundHeadingDegrees(194.2), 194);
});
test('roundHeadingDegrees wraps negative and multi-turn bearings', () => {
assert.equal(Object.is(roundHeadingDegrees(-0.4), 0), true);
assert.equal(roundHeadingDegrees(-0.6), 359);
assert.equal(roundHeadingDegrees(-1), 359);
assert.equal(roundHeadingDegrees(719.6), 0);
assert.equal(roundHeadingDegrees(-360.6), 359);
});
test('roundHeadingDegrees returns null for a value that is not finite', () => {
for (const value of [
Number.NaN,
Infinity,
-Infinity,
null,
undefined,
'12',
]) {
assert.equal(roundHeadingDegrees(value), null);
}
});
test('normalizeHeading keeps a positive bearing just under a half degree', () => {
assert.equal(roundHeadingDegrees(0.49999999999999), 0);
assert.equal(roundHeadingDegrees(359.49999999999994), 359);
assert.equal(normalizeHeading(-1e-15) < 360, true);
});
+2 -2
View File
@@ -5,6 +5,7 @@ import {
DIRECTION_CONE_HALF_ANGLE_DEG,
MARKER_SCALE_BY_DISTANCE,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* Badge scale at a camera distance, matching Cesium's NearFarScalar for the
@@ -49,9 +50,8 @@ export function alprLabelDetails(record, source) {
const details = [
sourceName === 'OpenStreetMap' ? 'OSM MAPPED' : `Source: ${sourceName}`,
];
// Wrap after rounding: 359.6° rounds to 360°, which is north.
if (Number.isFinite(record.directionDeg))
details.push(`DIRECTION ${Math.round(record.directionDeg) % 360}°`);
details.push(`DIRECTION ${roundHeadingDegrees(record.directionDeg)}°`);
const equipment = [
...new Set(
[record.manufacturer, record.operator, record.cameraType]
+3 -2
View File
@@ -1,3 +1,5 @@
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* Format a compass direction for the awareness overlay.
* @param {number} degrees
@@ -5,6 +7,5 @@
*/
export function formatAwarenessDirection(degrees) {
if (!Number.isFinite(degrees)) return '—';
const normalized = ((degrees % 360) + 360) % 360;
return String(Math.round(normalized) % 360).padStart(3, '0');
return String(roundHeadingDegrees(degrees)).padStart(3, '0');
}
+2 -2
View File
@@ -1,4 +1,5 @@
import { t } from '../../i18n/index.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
/**
* @module headingConfidence
@@ -44,8 +45,7 @@ export function isHeadingEstimated(camera) {
* @returns {string}
*/
export function headingHudToken(camera) {
// 359.5° and above rounds to north; modulo keeps it from printing 360°.
const hdg = Math.round(Number(camera?.headingDeg) || 0) % 360;
const hdg = roundHeadingDegrees(Number(camera?.headingDeg)) ?? 0;
return isHeadingEstimated(camera)
? t('HDG {value}° (ESTIMATED)', { value: hdg })
: t('HDG {value}°', { value: hdg });
+2 -2
View File
@@ -24,6 +24,7 @@ import {
TRAIL_COLOR,
CYAN_TRANSPARENT,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export function createTracking({
flightState,
@@ -125,9 +126,8 @@ export function createTracking({
speed: Number.isFinite(described.velocityMps)
? `${Math.round(described.velocityMps * 1.944)} kt`
: '',
// 359.5° and above rounds to north; modulo keeps it from reading 360°.
heading: Number.isFinite(described.track)
? `${Math.round(described.track) % 360}°`
? `${roundHeadingDegrees(described.track)}°`
: '',
route: route || '',
icao24,
+2 -1
View File
@@ -1,6 +1,7 @@
import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js';
import { formatNumber, t, tn } from '../../i18n/index.js';
import { BAND_LABELS, LOCAL_ADSB_COLOR, SOURCE_LABELS } from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
const DASH = '—';
@@ -152,7 +153,7 @@ export function localAdsbCardModel(
t('ALT {altitude} FT · GS {speed} KT · TRK {track}', {
altitude: grouped(altitude),
speed: speed === null ? DASH : speed,
track: track === null ? DASH : `${track % 360}°`,
track: track === null ? DASH : `${roundHeadingDegrees(track)}°`,
}),
vertical === null
? t('V/S {rate}', { rate: DASH })
+2 -2
View File
@@ -25,6 +25,7 @@ import {
AMBER_TRANSPARENT,
TRACKED_ICON_COLOR,
} from './policy.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export function createTracking({
flightState,
@@ -119,9 +120,8 @@ export function createTracking({
speed: Number.isFinite(described.velocityMps)
? `${Math.round(described.velocityMps * 1.944)} kt`
: '',
// 359.5° and above rounds to north; modulo keeps it from reading 360°.
heading: Number.isFinite(described.track)
? `${Math.round(described.track) % 360}°`
? `${roundHeadingDegrees(described.track)}°`
: '',
icao24,
// Honesty cue: the contact is coasting on dead reckoning, so the
+2 -2
View File
@@ -9,6 +9,7 @@ import {
transitRouteLabel,
} from '../../data/transitFeeds.js';
import { displayMotion, playbackPosition } from './movement.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export const TRANSIT_SELECTED_OVERLAY_SOURCE_ID = 'transit-selected';
export const TRANSIT_SELECTED_OVERLAY_SOURCE_OPTIONS = Object.freeze({
@@ -405,11 +406,10 @@ export function buildTransitSelectionCopy(
reportedMotion.push(
t('reported {speed} km/h', { speed: Math.round(record.speedMps * 3.6) }),
);
// 359.5° and above rounds to north; modulo keeps it from printing 360°.
if (Number.isFinite(record.bearing))
reportedMotion.push(
t('reported hdg {heading}°', {
heading: Math.round(record.bearing) % 360,
heading: roundHeadingDegrees(record.bearing),
}),
);
// The operator's own claim, kept as a REPORT rather than as the live state.
+6 -5
View File
@@ -4,6 +4,7 @@ import {
normalizeVesselType,
} from '../../data/vesselLabels.js';
import { formatNumber, t } from '../../i18n/index.js';
import { roundHeadingDegrees } from '../../data/headingDegrees.js';
export function createCards({
vesselState,
@@ -55,10 +56,8 @@ export function createCards({
if (record.speed !== null && record.speed !== undefined)
parts.push(formatSpeed(record.speed));
const direction = record.heading ?? record.course;
// A course of 359.5° and above rounds to north; modulo keeps it from
// printing 360°. The selected card and the HUD wrap the same way.
if (Number.isFinite(direction))
parts.push(`${Math.round(direction) % 360}°`);
parts.push(`${roundHeadingDegrees(direction)}°`);
return {
id: vesselOverlayEntryId(record),
actionable: Boolean(record?.mmsi),
@@ -89,7 +88,9 @@ export function createCards({
[
vesselTypeShort(record) || t('VESSEL'),
formatSpeed(record.speed),
Number.isFinite(direction) ? `${Math.round(direction) % 360}°` : '--°',
Number.isFinite(direction)
? `${roundHeadingDegrees(direction)}°`
: '--°',
].join(' · '),
];
const destination = String(record.destination || '').trim();
@@ -179,7 +180,7 @@ export function createCards({
function formatHeading(heading) {
return t('{heading}DEG', {
heading: Number.isFinite(heading) ? Math.round(heading) % 360 : '--',
heading: roundHeadingDegrees(heading) ?? '--',
});
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
import { createCctvVideoSurface } from './cctvVideo.js';
import { presentCctvCalibration } from './cctvCalibration.js';
import { roundHeadingDegrees } from '../data/headingDegrees.js';
export function _calBadgeLabel(badge) {
switch (badge) {
case 'calibrated':
@@ -107,7 +108,7 @@ function metaText(state, enabled, activeCamera, catalogCount) {
'{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}',
{
city: activeCamera.city,
heading: `${Math.round(activeCamera.headingDeg) % 360}°`,
heading: `${roundHeadingDegrees(Number(activeCamera.headingDeg)) ?? 0}°`,
fov: `${Math.round(activeCamera.fovDeg)}°`,
range: Math.round(activeCamera.rangeM),
projection: projLabel,
+1 -1
View File
@@ -120,7 +120,7 @@ export function updateHud(
(tick) => formatAltitudeRulerTick(tick.valueFt),
);
view.heading = {
text: String(Math.round(heading) % 360).padStart(3, '0'),
text: formatHeadingReadout(heading),
value: heading,
immediate: forceContext,
};
+6 -3
View File
@@ -1,6 +1,10 @@
/** Pure formatting and fixed presentation constants used by Cockpit components. */
import { formatNumber, getLocale, isolate, t, msg } from '../i18n/index.js';
import { formatQuantity } from './unitLabels.js';
import {
normalizeHeading,
roundHeadingDegrees,
} from '../data/headingDegrees.js';
export const COCKPIT_HEADING_SLEW_DPS = 28;
@@ -135,8 +139,7 @@ export function cockpitUnitCase(text) {
export function formatCockpitWindDirection(value) {
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
const labels = Object.keys(COMPASS_POINT_LABELS);
const normalized = ((value % 360) + 360) % 360;
// 359.5° and above is north. Modulo keeps that from printing 360°.
const degrees = Math.round(normalized) % 360;
const normalized = normalizeHeading(value);
const degrees = roundHeadingDegrees(value);
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
}
+2 -3
View File
@@ -1,6 +1,7 @@
/** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */
import { t } from '../i18n/index.js';
import {
formatHeadingReadout,
normalizeHeading,
resolveTrackedAircraftInfo,
} from '../cockpitMath.js';
@@ -186,9 +187,7 @@ export function enter() {
this.startBriefRotation();
const trackLabel =
info.callsign || info.registration || info.icao24 || t('AIRCRAFT');
const trackHeading = String(
Math.round(normalizeHeading(info.track ?? 0)),
).padStart(3, '0');
const trackHeading = formatHeadingReadout(info.track ?? 0);
this.pushCockpitSignal(
'track',
'track',
+2 -2
View File
@@ -7,6 +7,7 @@ import {
translateKnown,
} from '../i18n/index.js';
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
import { roundHeadingDegrees } from '../data/headingDegrees.js';
// Street Level UI state to panel strings and flags. Pure, so it is testable.
@@ -175,9 +176,8 @@ function presentViewer(state) {
const { street } = state;
const right = [];
if (street.isPano) right.push('360°');
// 359.5° and above rounds to north; modulo keeps it from printing 360°.
if (Number.isFinite(street.bearing))
right.push(`${Math.round(street.bearing) % 360}°`);
right.push(`${roundHeadingDegrees(street.bearing)}°`);
if (street.capturedAt)
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
const left = [];