From a15fdd31340872a32e84453d1e59b48eb519b720 Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:59:09 -0400 Subject: [PATCH] fix(hud): carry rounded DMS seconds into the minutes (#1339) Carry rounded HUD coordinate seconds into minutes and degrees. Contribution by @rudycelekli. --- changelog.d/1339-hud-dms-carry.md | 3 + docs/CURRENT-STATE.md | 6 ++ scripts/format-scope.json | 3 +- src/hud.js | 11 ++-- src/hudDms.test.mjs | 106 ++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 changelog.d/1339-hud-dms-carry.md create mode 100644 src/hudDms.test.mjs diff --git a/changelog.d/1339-hud-dms-carry.md b/changelog.d/1339-hud-dms-carry.md new file mode 100644 index 000000000..588668afe --- /dev/null +++ b/changelog.d/1339-hud-dms-carry.md @@ -0,0 +1,3 @@ +- Carry HUD LAT/LON seconds that round up to 60.00" into the minutes and + degrees, so longitude -122.35 reads `122°21'00.00"W` instead of + `122°20'60.00"W` (rudycelekli, #1339). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 98214e541..bfbfcca90 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4,6 +4,12 @@ Docker Compose mounts the checkout with the shared SELinux `:z` label so containers can read it on enforcing SELinux hosts. The separate image-owned `node_modules` volume and loopback-only port publishing remain unchanged. +The HUD's LAT/LON degrees-minutes-seconds readout rounds the coordinate once, to +hundredths of a second, and then splits it. A value within 0.005" of the next +whole minute carries into the minutes and degrees, so `-122.35` reads +`122°21'00.00"W` rather than `122°20'60.00"W`. The field widths and hemisphere +letters are unchanged. + Aircraft detection candidates reuse normalized display text until callsign or registration changes, including in-place enrichment. Each candidate resolves its fleet model once per collection. Positions, model ownership and tracked-aircraft diff --git a/scripts/format-scope.json b/scripts/format-scope.json index d62e8bb47..20e28122a 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1087,5 +1087,6 @@ "scripts/qa-status-badges.mjs", "src/dockerTrustedPeers.test.mjs", "src/cameraGlobeRotation.test.mjs", - "src/voice/globeResetConfirmation.test.mjs" + "src/voice/globeResetConfirmation.test.mjs", + "src/hudDms.test.mjs" ] diff --git a/src/hud.js b/src/hud.js index cf7983da7..1428f8503 100644 --- a/src/hud.js +++ b/src/hud.js @@ -574,11 +574,12 @@ export class IntelHUD { * @returns {string} Formatted DMS string, e.g. `"38°53'23.10"N"`. */ _toDMS(decimal, type) { - const abs = Math.abs(decimal); - const deg = Math.floor(abs); - const minFloat = (abs - deg) * 60; - const min = Math.floor(minFloat); - const sec = ((minFloat - min) * 60).toFixed(2); + // Round once, in hundredths of a second, so a value that rounds up to + // 60.00" carries into the minutes and degrees instead of printing 60. + const hundredths = Math.round(Math.abs(decimal) * 360000); + const deg = Math.floor(hundredths / 360000); + const min = Math.floor(hundredths / 6000) % 60; + const sec = ((hundredths % 6000) / 100).toFixed(2); let dir; if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S'; diff --git a/src/hudDms.test.mjs b/src/hudDms.test.mjs new file mode 100644 index 000000000..064906b5f --- /dev/null +++ b/src/hudDms.test.mjs @@ -0,0 +1,106 @@ +// The HUD's LAT/LON readout in degrees-minutes-seconds. Seconds are printed to +// two decimals, so a value within 0.005" of the next whole minute rounds up. +// That carry must reach the minutes (and degrees): `122°20'60.00"W` is not a +// coordinate. Ordinary decimal inputs land there through binary floating +// point: 122.35 - 122 is 0.34999999999999432, so -122.35 read 20'60.00". +// +// hud.js imports `mgrs`, a CommonJS package whose named exports Node's ESM +// loader cannot see, so this installs the same one-specifier stub as +// hudAltitudeDatum.test.mjs before importing it. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; + +const MGRS_STUB_URL = 'gev-test-stub:mgrs'; +registerHooks({ + resolve(specifier, context, next) { + if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true }; + return next(specifier, context); + }, + load(url, context, next) { + if (url === MGRS_STUB_URL) { + return { + format: 'module', + shortCircuit: true, + source: + 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', + }; + } + return next(url, context); + }, +}); + +const { IntelHUD } = await import('./hud.js'); + +const toDMS = (decimal, type) => IntelHUD.prototype._toDMS(decimal, type); + +test('a rounded-up second carries into the minutes', () => { + assert.equal(toDMS(-122.35, 'lon'), `122°21'00.00"W`); + assert.equal(toDMS(139.7, 'lon'), `139°42'00.00"E`); + assert.equal(toDMS(37.0166666, 'lat'), `37°01'00.00"N`); +}); + +test('a carry through 59 minutes reaches the degrees', () => { + assert.equal(toDMS(38.999999, 'lat'), `39°00'00.00"N`); + assert.equal(toDMS(-0.999999, 'lat'), `01°00'00.00"S`); + assert.equal(toDMS(179.999999, 'lon'), `180°00'00.00"E`); +}); + +test('ordinary values keep their existing format', () => { + assert.equal(toDMS(38.889722, 'lat'), `38°53'23.00"N`); + assert.equal(toDMS(-77.035278, 'lon'), `077°02'07.00"W`); + assert.equal(toDMS(51.5, 'lat'), `51°30'00.00"N`); + assert.equal(toDMS(-33.8568, 'lat'), `33°51'24.48"S`); + assert.equal(toDMS(0, 'lon'), `000°00'00.00"E`); + assert.equal(toDMS(90, 'lat'), `90°00'00.00"N`); +}); + +test('the telemetry tick paints the carried value into both readouts', () => { + const elements = new Map( + [ + 'hud-alt', + 'hud-summary', + 'hud-mgrs', + 'hud-latlon', + 'hud-bottom-line', + 'hud-gsd', + 'hud-coll', + 'hud-ona', + 'hud-mode', + ].map((id) => [id, { textContent: '' }]), + ); + const previousDocument = globalThis.document; + globalThis.document = { + getElementById: (id) => elements.get(id) ?? null, + querySelectorAll: () => [], + addEventListener() {}, + removeEventListener() {}, + }; + const viewer = { + camera: { + pitch: -Math.PI / 2, + positionCartographic: { + latitude: (37.75 * Math.PI) / 180, + longitude: (-122.35 * Math.PI) / 180, + height: 1500, + }, + computeViewRectangle: () => undefined, + moveStart: { addEventListener() {}, removeEventListener() {} }, + moveEnd: { addEventListener() {}, removeEventListener() {} }, + }, + }; + let hud; + try { + hud = new IntelHUD(viewer); + hud._updateCameraData(); + const latlon = elements.get('hud-latlon').textContent; + const bottom = elements.get('hud-bottom-line').textContent; + assert.equal(latlon, `37°45'00.00"N 122°21'00.00"W`); + assert.match(bottom, /LON: 122°21'00\.00"W/); + assert.doesNotMatch(`${latlon} ${bottom}`, /60\.00"/); + } finally { + hud?.destroy(); + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + } +});