From 5f27f6edf4cc7f3ce2b2b593e1890c700da1d40f Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 17 Sep 2026 10:20:26 +0200 Subject: [PATCH] =?UTF-8?q?fix(cctv):=20surface=20heading=20confidence=20?= =?UTF-8?q?=E2=80=94=20estimated=20bearings=20are=20now=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every pack sets headingConfidence on each camera, but nothing consumed it: a bearing synthesized by fallbackHeadingFromId (a hash of the id string, no geographic input) rendered exactly like a surveyed one — 2,827 of 3,997 cameras in a default catalog. Fixes #639. Two consumers, plus a passthrough: - HUD: the heading token is tagged when the bearing is synthetic — `HDG 194° (ESTIMATED)` — so a hash never reads as a measured facing. - Wireframe: a low-confidence camera draws its coverage frustum with dashed polylines instead of solid ones. Colors, widths, and the active/idle emphasis are unchanged; the dash is the only signal. - Public camera state gains `headingConfidence` (the raw pack flag) and `headingEstimated` (the derived bit) so UI consumers never re-derive it. A human-vouched pose always wins over the pack flag: a manually saved calibration (calSource:'manual') or a curated catalog entry (poseSource:'curated') is never presented as estimated, mirroring the CAL badge states in calibration.js. - src/layers/cctv/headingConfidence.js: pure helpers (isHeadingEstimated, headingHudToken) - src/layers/cctv/headingConfidence.test.mjs: confidence normalization, calibration/curation overrides, HUD token formatting - src/layers/cctv/presentation.js: HUD token + public-state passthrough - src/layers/cctv/rendering.js: dashed materials for estimated bearings --- src/layers/cctv/headingConfidence.js | 47 +++++++++++++++++ src/layers/cctv/headingConfidence.test.mjs | 60 ++++++++++++++++++++++ src/layers/cctv/presentation.js | 10 +++- src/layers/cctv/rendering.js | 42 ++++++++++----- 4 files changed, 144 insertions(+), 15 deletions(-) create mode 100644 src/layers/cctv/headingConfidence.js create mode 100644 src/layers/cctv/headingConfidence.test.mjs diff --git a/src/layers/cctv/headingConfidence.js b/src/layers/cctv/headingConfidence.js new file mode 100644 index 000000000..a42297e28 --- /dev/null +++ b/src/layers/cctv/headingConfidence.js @@ -0,0 +1,47 @@ +/** + * @module headingConfidence + * + * Consumers for the catalog's per-camera `headingConfidence` flag (#639). + * + * Packs whose feed publishes no facing get a synthetic bearing from + * `fallbackHeadingFromId` — a hash of the id string, with no geographic + * input — and are marked `headingConfidence: 'low'`. Until now nothing read + * the flag, so a guessed bearing rendered exactly like a surveyed one. These + * helpers make the guess visible: the HUD heading token gains an + * `(ESTIMATED)` tag and the coverage wireframe draws dashed instead of solid. + * + * A human-vouched pose always wins over the pack flag: a manually saved + * calibration (`calSource:'manual'`) or a hand-authored catalog entry + * (`poseSource:'curated'`) is never presented as estimated, mirroring the + * CAL badge states in calibration.js. + */ + +/** + * Whether a camera's bearing is a synthetic guess that should present as + * provisional. + * @param {{headingConfidence?: string|null, calSource?: string|null, + * poseSource?: string|null}} camera - Catalog camera (or public state). + * @returns {boolean} + */ +export function isHeadingEstimated(camera) { + if (!camera) return false; + if (camera.calSource === 'manual') return false; + if (camera.poseSource === 'curated') return false; + return ( + String(camera.headingConfidence || '') + .trim() + .toLowerCase() === 'low' + ); +} + +/** + * HUD heading token: `HDG 194°`, tagged `(ESTIMATED)` when the bearing is + * synthetic — so a hash can never read as a surveyed facing. + * @param {{headingDeg?: number, headingConfidence?: string|null, + * calSource?: string|null, poseSource?: string|null}} camera + * @returns {string} + */ +export function headingHudToken(camera) { + const hdg = Math.round(Number(camera?.headingDeg) || 0); + return `HDG ${hdg}°${isHeadingEstimated(camera) ? ' (ESTIMATED)' : ''}`; +} diff --git a/src/layers/cctv/headingConfidence.test.mjs b/src/layers/cctv/headingConfidence.test.mjs new file mode 100644 index 000000000..513bf0d1b --- /dev/null +++ b/src/layers/cctv/headingConfidence.test.mjs @@ -0,0 +1,60 @@ +// src/layers/cctv/headingConfidence.test.mjs +// Bearing-provenance consumers (#639): a pack's headingConfidence:'low' +// (a hashed synthetic bearing) must present as estimated — unless a human +// vouched for the pose via manual calibration or a curated catalog entry. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isHeadingEstimated, headingHudToken } from './headingConfidence.js'; + +test('low confidence marks the bearing estimated', () => { + assert.equal(isHeadingEstimated({ headingConfidence: 'low' }), true); +}); + +test('confidence matching is trimmed and case-insensitive', () => { + assert.equal(isHeadingEstimated({ headingConfidence: ' LOW ' }), true); + assert.equal(isHeadingEstimated({ headingConfidence: 'Low' }), true); +}); + +test('medium, high, missing, and absent cameras are not estimated', () => { + assert.equal(isHeadingEstimated({ headingConfidence: 'medium' }), false); + assert.equal(isHeadingEstimated({ headingConfidence: 'high' }), false); + assert.equal(isHeadingEstimated({}), false); + assert.equal(isHeadingEstimated(null), false); + assert.equal(isHeadingEstimated(undefined), false); +}); + +test('a manually saved calibration overrides a low pack flag', () => { + assert.equal( + isHeadingEstimated({ headingConfidence: 'low', calSource: 'manual' }), + false, + ); +}); + +test('a curated catalog entry overrides a low pack flag', () => { + assert.equal( + isHeadingEstimated({ headingConfidence: 'low', poseSource: 'curated' }), + false, + ); +}); + +test('HUD token tags a synthetic bearing and rounds the degrees', () => { + assert.equal( + headingHudToken({ headingDeg: 194.4, headingConfidence: 'low' }), + 'HDG 194° (ESTIMATED)', + ); +}); + +test('HUD token stays untagged for surveyed and calibrated bearings', () => { + assert.equal( + headingHudToken({ headingDeg: 67.5, headingConfidence: 'high' }), + 'HDG 68°', + ); + assert.equal( + headingHudToken({ + headingDeg: 225, + headingConfidence: 'low', + calSource: 'manual', + }), + 'HDG 225°', + ); +}); diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index 742869877..abda57d02 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -1,5 +1,6 @@ import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js'; +import { headingHudToken, isHeadingEstimated } from './headingConfidence.js'; export function createPresentation({ state: layerState, @@ -35,7 +36,9 @@ export function createPresentation({ return [ `${active.camera.city.toUpperCase()} CCTV`, `${active.camera.name.toUpperCase()}`, - `HDG ${Math.round(active.camera.headingDeg)}°`, + // A synthetic bearing (headingConfidence 'low', no human calibration) + // is tagged so a hashed guess never reads as a surveyed facing (#639). + headingHudToken(active.camera), `FOV ${Math.round(active.camera.fovDeg)}°`, `COVERAGE ${area.toFixed(2)}km²`, overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW', @@ -76,6 +79,11 @@ export function createPresentation({ lat: camera.lat, lon: camera.lon, headingDeg: camera.headingDeg, + // Bearing provenance (#639): the pack's confidence flag plus the derived + // "is this a synthetic guess" bit (calibration-aware), so UI consumers + // never have to re-derive it. + headingConfidence: camera.headingConfidence || null, + headingEstimated: isHeadingEstimated(camera), pitchDeg: camera.pitchDeg, fovDeg: camera.fovDeg, rangeM: camera.rangeM, diff --git a/src/layers/cctv/rendering.js b/src/layers/cctv/rendering.js index 8c4dbb9be..ad8de36b4 100644 --- a/src/layers/cctv/rendering.js +++ b/src/layers/cctv/rendering.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { horizonOccluder } from '../../data/iconOrientation.js'; +import { isHeadingEstimated } from './headingConfidence.js'; import { ACTIVE_CAMERA_COLOR, IDLE_CAMERA_COLOR, @@ -228,6 +229,15 @@ export function createRendering({ ); const inVisibleSet = coverageVisible.has(record.camera.id); + // A synthetic bearing (headingConfidence 'low', no human calibration) + // draws its wireframe dashed so a guessed facing is visibly provisional + // rather than rendering identically to a surveyed one (#639). Colors, + // widths, and the active/idle emphasis are unchanged. + const bearingEstimated = isHeadingEstimated(record.camera); + const lineMaterial = (color) => + bearingEstimated + ? new Cesium.PolylineDashMaterialProperty({ color }) + : color; for (const entity of record.coverageEntities || []) { // The frustum wireframe is part of the projection representation — // force it on for the active camera and let it read through geometry @@ -242,13 +252,15 @@ export function createRendering({ // active camera keeps its width/alpha emphasis in both schemes. const hue = viewshedOn ? record.viewshedColors : null; if (entity._coverageRole === 'cap') { - entity.polyline.material = hue - ? isActive - ? hue.lineActive - : hue.line - : isActive - ? ACTIVE_COVERAGE_CENTER - : IDLE_COVERAGE_CENTER_MUTED; + entity.polyline.material = lineMaterial( + hue + ? isActive + ? hue.lineActive + : hue.line + : isActive + ? ACTIVE_COVERAGE_CENTER + : IDLE_COVERAGE_CENTER_MUTED, + ); entity.polyline.width = isActive ? 2.2 : 1.0; entity.polyline.depthFailMaterial = planeShowing ? hue @@ -256,13 +268,15 @@ export function createRendering({ : ACTIVE_COVERAGE_CENTER_DEPTHFAIL : undefined; } else { - entity.polyline.material = hue - ? isActive - ? hue.lineActive - : hue.line.withAlpha(0.6) - : isActive - ? ACTIVE_COVERAGE_EDGE - : IDLE_COVERAGE_EDGE_MUTED; + entity.polyline.material = lineMaterial( + hue + ? isActive + ? hue.lineActive + : hue.line.withAlpha(0.6) + : isActive + ? ACTIVE_COVERAGE_EDGE + : IDLE_COVERAGE_EDGE_MUTED, + ); entity.polyline.width = isActive ? 1.8 : 0.9; entity.polyline.depthFailMaterial = planeShowing ? hue