diff --git a/CHANGELOG.md b/CHANGELOG.md index 527e1a29a..29dbedfaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +- CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived + from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the + flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD + now reads `HDG n° (ESTIMATED)` and the coverage wireframe draws dashed; manual + calibrations and curated poses are never marked estimated (bassem chagra, #643). + - Report which upstream declined a Street Traffic road load. The layer row now reads `Overpass rate-limited`, `Overpass timed out`, or `Overpass refused the road query (HTTP 406)` instead of a general diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 59f021aba..fdb42fc0c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3384,6 +3384,12 @@ silently demoting every later lookup for the session. - **The DENSE chip reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `DENSE ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `DENSE ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale. - **A dependency owner takes the row with it.** Space Missions borrows this layer for TLE lookup with `showPoints:false`; while points are hidden the layer returns empty row controls, so the legend never describes an empty sky and the chip cannot accept a write that the owner's restore would silently revert. - The detection-overlay record cache (`_detectionObjects`) is cleared with the catalog on every rebuild: it stamps id/class at creation only, and a rebuild can re-tag a satellite when a partial CelesTrak outage changes which group wins dedupe. +- **CCTV estimated bearings:** a camera whose pack marks `headingConfidence: 'low'` (the id-hash + `fallbackHeadingFromId` bearing) shows `HDG n° (ESTIMATED)` in the HUD and draws its coverage + 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). - **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP. - **CCTV v2 foundation:** a pitched frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 8e9c9a61c..189192f10 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1072,6 +1072,7 @@ "src/layers/cctv/geometry.js", "src/layers/cctv/geometryQueue.js", "src/layers/cctv/ground.js", + "src/layers/cctv/headingConfidence.js", "src/layers/cctv/health.js", "src/layers/cctv/hover.js", "src/layers/cctv/index.js", @@ -1488,6 +1489,7 @@ "src/layers/cctv/geometry.js", "src/layers/cctv/geometryQueue.js", "src/layers/cctv/ground.js", + "src/layers/cctv/headingConfidence.js", "src/layers/cctv/health.js", "src/layers/cctv/hover.js", "src/layers/cctv/index.js", @@ -2127,6 +2129,7 @@ "src/layers/cctv/geometry.js", "src/layers/cctv/geometryQueue.js", "src/layers/cctv/ground.js", + "src/layers/cctv/headingConfidence.js", "src/layers/cctv/health.js", "src/layers/cctv/hover.js", "src/layers/cctv/index.js", 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 1ee54df50..f8f83fcd1 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 faac69eb8..6601d938b 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, @@ -233,6 +234,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 @@ -247,13 +257,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 @@ -261,13 +273,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