From 940050d4e5abd244b291408f9de8cb5e8277de4a Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:06:07 +0530 Subject: [PATCH 1/3] =?UTF-8?q?fix(cctv):=20show=20a=20camera=20facing=203?= =?UTF-8?q?59.6=C2=B0=20as=20HDG=200=C2=B0,=20not=20HDG=20360=C2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CCTV HUD heading token and the camera panel's meta line rounded the bearing without wrapping, so a camera facing 359.5° or more (a feed bearing, or a heading calibrated just west of north) read "HDG 360°". Both now wrap after rounding: 359.6° reads "HDG 0°", 359.4° "HDG 359°". --- docs/CURRENT-STATE.md | 4 +++- src/layers/cctv/headingConfidence.js | 3 ++- src/layers/cctv/headingConfidence.test.mjs | 9 ++++++++ src/ui/cctvControls.test.mjs | 24 ++++++++++++++++++++++ src/ui/cctvPresentation.js | 2 +- 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c55031a6a..c3e87e8d6 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4788,7 +4788,9 @@ silently demoting every later lookup for the session. wireframe dashed; colours, widths and active/idle emphasis are unchanged. A manual calibration (`calSource: 'manual'`) or curated pose (`poseSource: 'curated'`) is never presented as estimated, matching the CAL badge. Public camera state carries `headingConfidence` and - `headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). + `headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). The HUD token and the + camera panel's meta line wrap the bearing after rounding to whole degrees, so a camera + facing 359.5° or more reads `HDG 0°` rather than `HDG 360°`. - **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens diff --git a/src/layers/cctv/headingConfidence.js b/src/layers/cctv/headingConfidence.js index 26a877558..fad0867de 100644 --- a/src/layers/cctv/headingConfidence.js +++ b/src/layers/cctv/headingConfidence.js @@ -44,7 +44,8 @@ export function isHeadingEstimated(camera) { * @returns {string} */ export function headingHudToken(camera) { - const hdg = Math.round(Number(camera?.headingDeg) || 0); + // 359.5° and above rounds to north; modulo keeps it from printing 360°. + const hdg = Math.round(Number(camera?.headingDeg) || 0) % 360; return isHeadingEstimated(camera) ? t('HDG {value}° (ESTIMATED)', { value: hdg }) : t('HDG {value}°', { value: hdg }); diff --git a/src/layers/cctv/headingConfidence.test.mjs b/src/layers/cctv/headingConfidence.test.mjs index 513bf0d1b..3cf673861 100644 --- a/src/layers/cctv/headingConfidence.test.mjs +++ b/src/layers/cctv/headingConfidence.test.mjs @@ -44,6 +44,15 @@ test('HUD token tags a synthetic bearing and rounds the degrees', () => { ); }); +test('HUD token reads a bearing that rounds to north as 0°, not 360°', () => { + assert.equal(headingHudToken({ headingDeg: 359.4 }), 'HDG 359°'); + assert.equal(headingHudToken({ headingDeg: 359.6 }), 'HDG 0°'); + assert.equal( + headingHudToken({ headingDeg: 359.5, headingConfidence: 'low' }), + 'HDG 0° (ESTIMATED)', + ); +}); + test('HUD token stays untagged for surveyed and calibrated bearings', () => { assert.equal( headingHudToken({ headingDeg: 67.5, headingConfidence: 'high' }), diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 6a47ff91f..23e5ea75e 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -47,6 +47,30 @@ function fixture(t) { return { controls, requests }; } +test('the panel meta line reads a bearing that rounds to north as HDG 0°', (t) => { + const { controls } = fixture(t); + controls.actions.setPanelCollapsed = () => {}; + controls._cctvMeta = element(); + const meta = (headingDeg) => { + const camera = { + id: 'a', + city: 'Austin', + headingDeg, + fovDeg: 74, + rangeM: 700, + }; + controls._renderCctvState({ + enabled: true, + activeCameraId: camera.id, + activeCamera: camera, + cameras: [camera], + }); + return controls._cctvMeta.textContent; + }; + assert.match(meta(359.4), /· HDG 359° ·/); + assert.match(meta(359.6), /· HDG 0° ·/); +}); + test('a late image completion cannot replace a newer camera preview', (t) => { const { controls, requests } = fixture(t); controls._queueCctvFrame('first.jpg', 'a', true); diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index 070c47f64..e4dffa98e 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -171,7 +171,7 @@ export function _renderCctvState(state) { '{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}', { city: activeCamera.city, - heading: `${Math.round(activeCamera.headingDeg)}°`, + heading: `${Math.round(activeCamera.headingDeg) % 360}°`, fov: `${Math.round(activeCamera.fovDeg)}°`, range: Math.round(activeCamera.rangeM), projection: projLabel, From fdcbcdaaef39373c5ce2981914ade6a4185add9e Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:24:02 +0530 Subject: [PATCH 2/3] docs(changelog): add #1441 fragment --- changelog.d/1441-cctv-heading-north.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1441-cctv-heading-north.md diff --git a/changelog.d/1441-cctv-heading-north.md b/changelog.d/1441-cctv-heading-north.md new file mode 100644 index 000000000..d65e5c2d8 --- /dev/null +++ b/changelog.d/1441-cctv-heading-north.md @@ -0,0 +1,3 @@ +- Show a CCTV camera facing 359.5° or more as HDG 0°. The HUD heading token + and the camera panel rounded the bearing without wrapping, so a camera + facing 359.6° read "HDG 360°" (Aditya, @Rohilalala, #1441). From af79691a190b692963f4ff5793a6f9af787f0963 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 1 Nov 2026 20:53:32 -0800 Subject: [PATCH 3/3] test(cctv): check the north-rounding meta line through presentCctvPanel The panel now renders from the CCTV store, so the meta line is no longer written to a DOM node by _renderCctvState. Assert on presentCctvPanel's meta text instead. --- src/ui/cctvControls.test.mjs | 24 ------------------------ src/ui/cctvPanel.test.mjs | 23 +++++++++++++++++++++++ 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 5389bf0f4..9a5d746d7 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -47,30 +47,6 @@ function fixture(t) { return { controls, requests }; } -test('the panel meta line reads a bearing that rounds to north as HDG 0°', (t) => { - const { controls } = fixture(t); - controls.actions.setPanelCollapsed = () => {}; - controls._cctvMeta = element(); - const meta = (headingDeg) => { - const camera = { - id: 'a', - city: 'Austin', - headingDeg, - fovDeg: 74, - rangeM: 700, - }; - controls._renderCctvState({ - enabled: true, - activeCameraId: camera.id, - activeCamera: camera, - cameras: [camera], - }); - return controls._cctvMeta.textContent; - }; - assert.match(meta(359.4), /· HDG 359° ·/); - assert.match(meta(359.6), /· HDG 0° ·/); -}); - test('a late image completion cannot replace a newer camera preview', (t) => { const { controls, requests } = fixture(t); controls._queueCctvFrame('first.jpg', 'a', true); diff --git a/src/ui/cctvPanel.test.mjs b/src/ui/cctvPanel.test.mjs index 1675d6119..a3dbef786 100644 --- a/src/ui/cctvPanel.test.mjs +++ b/src/ui/cctvPanel.test.mjs @@ -490,3 +490,26 @@ test('the scene summary types in, then holds the whole text', async (t) => { await nextTick(); assert.equal(byId('cctv-summary').textContent, text); }); + +test('the panel meta line reads a bearing that rounds to north as HDG 0°', () => { + const meta = (headingDeg) => { + const camera = { + id: 'a', + city: 'Austin', + headingDeg, + fovDeg: 74, + rangeM: 700, + }; + return presentCctvPanel( + { + enabled: true, + activeCameraId: camera.id, + activeCamera: camera, + cameras: [camera], + }, + true, + ).meta; + }; + assert.match(meta(359.4), /· HDG 359° ·/); + assert.match(meta(359.6), /· HDG 0° ·/); +});