From 672191497a58fbf0e2b1ee1fad5543fb7c6d0b13 Mon Sep 17 00:00:00 2001 From: Daniel Slay <99130682+danielslay86@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:31:47 -0400 Subject: [PATCH 1/9] feat(cctv): color live video cameras apart from stills --- docs/CURRENT-STATE.md | 2 +- src/layers/cctv/policy.js | 3 +++ src/layers/cctv/rendering.js | 19 ++++++++++++------- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 105fdd37f..734e722d7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4359,7 +4359,7 @@ silently demoting every later lookup for the session. ground clamp now lifts the cap _center_ only so the wireframe stays a true pyramid welded to the plane (was a flattened fan / the ~47.5 m divergence — RESOLVED), re-selecting the active camera is a no-op (killed a click-flash), and texture swaps gate on canvas content (killed a - periodic white flash). Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES` + periodic white flash). Camera icons are cyan for stills, violet for cameras with a live video feed, and amber for the active camera. Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES` default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of 1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego, Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default diff --git a/src/layers/cctv/policy.js b/src/layers/cctv/policy.js index cf09ebe64..7ba4e1231 100644 --- a/src/layers/cctv/policy.js +++ b/src/layers/cctv/policy.js @@ -410,6 +410,9 @@ export const IDLE_CAMERA_COLOR = export const ACTIVE_CAMERA_COLOR = Cesium.Color.fromCssColorString('#ffd97a').withAlpha(0.95); +export const LIVE_CAMERA_COLOR = + Cesium.Color.fromCssColorString('#b48cff').withAlpha(0.88); + export const IDLE_COVERAGE_COLOR = Cesium.Color.fromCssColorString('#2fe0ff').withAlpha(0.24); diff --git a/src/layers/cctv/rendering.js b/src/layers/cctv/rendering.js index 6601d938b..d10368d40 100644 --- a/src/layers/cctv/rendering.js +++ b/src/layers/cctv/rendering.js @@ -4,6 +4,7 @@ import { isHeadingEstimated } from './headingConfidence.js'; import { ACTIVE_CAMERA_COLOR, IDLE_CAMERA_COLOR, + LIVE_CAMERA_COLOR, ACTIVE_COVERAGE_CENTER, IDLE_COVERAGE_CENTER_MUTED, ACTIVE_COVERAGE_CENTER_DEPTHFAIL, @@ -18,6 +19,15 @@ export function createRendering({ parts, source, }) { + /** Icon color: active wins, then live-capable video feeds, then idle stills. */ + function cameraColorFor(record) { + if (record.camera.id === layerState._activeCameraId) + return ACTIVE_CAMERA_COLOR; + if (parts.model.isVideoFeedType(record.camera.feedType)) + return LIVE_CAMERA_COLOR; + return IDLE_CAMERA_COLOR; + } + const { focusNowMs, getFocusTarget, @@ -58,10 +68,7 @@ export function createRendering({ ), cameraDistanceFor: (position) => Cesium.Cartesian3.distance(camera.positionWC, position), - baseColorFor: (record) => - record.camera.id === layerState._activeCameraId - ? ACTIVE_CAMERA_COLOR - : IDLE_CAMERA_COLOR, + baseColorFor: cameraColorFor, }); layerState._activeFocusStyleCount = result.activeCount; } @@ -208,9 +215,7 @@ export function createRendering({ for (const record of layerState._records) { const isActive = record.camera.id === activeId; if (record.billboard) { - record.billboard.color = isActive - ? ACTIVE_CAMERA_COLOR - : IDLE_CAMERA_COLOR; + record.billboard.color = cameraColorFor(record); record.billboard.scale = isActive ? 1.25 : 1.0; // disableDepthTestDistance stays POSITIVE_INFINITY for every billboard // (set at creation) — see the field-test far-zoom submerge fix there. From a1bd7ff37ca90ef08b7db85d239333ccdfa6d840 Mon Sep 17 00:00:00 2001 From: Daniel Slay <99130682+danielslay86@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:01:04 -0400 Subject: [PATCH 2/9] feat(cctv): draw stills at source aspect, pillarboxed --- docs/CURRENT-STATE.md | 2 +- src/layers/cctv/frames.js | 35 ++++++++++++++++++++++++++++------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 105fdd37f..24fa1a16d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4341,7 +4341,7 @@ silently demoting every later lookup for the session. - **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 - far cap, retargeting the existing video/canvas texture pipeline. Manual calibration only — + far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only — auto-calibration and the drape mesh pipeline are deleted. A one-shot activation obstruction probe (`pickFromRay` on camera activation, clamping the plane short of the first hit) remains; ground placement is superseded by the shared-floor v3 behavior below. Calibration persists to diff --git a/src/layers/cctv/frames.js b/src/layers/cctv/frames.js index d137b74b2..7e037f198 100644 --- a/src/layers/cctv/frames.js +++ b/src/layers/cctv/frames.js @@ -12,6 +12,28 @@ import { PLACEHOLDER_REPAINT_MS, } from './policy.js'; +function fitToProjection(sourceW, sourceH) { + if (!sourceW || !sourceH) + return { + x: 0, + y: 0, + w: PROJECTION_CANVAS_WIDTH, + h: PROJECTION_CANVAS_HEIGHT, + }; + const scale = Math.min( + PROJECTION_CANVAS_WIDTH / sourceW, + PROJECTION_CANVAS_HEIGHT / sourceH, + ); + const w = Math.round(sourceW * scale); + const h = Math.round(sourceH * scale); + return { + x: Math.round((PROJECTION_CANVAS_WIDTH - w) / 2), + y: Math.round((PROJECTION_CANVAS_HEIGHT - h) / 2), + w, + h, + }; +} + export function createFrames({ state: layerState, services, parts, source }) { /** * FNV-1a over the RGB channels of a downsampled frame. Pure (takes the raw @@ -328,19 +350,18 @@ export function createFrames({ state: layerState, services, parts, source }) { return; } runtime.lastFrameSignature = signature; - runtime.ctx.clearRect( + runtime.ctx.fillStyle = '#01070c'; + runtime.ctx.fillRect( 0, 0, PROJECTION_CANVAS_WIDTH, PROJECTION_CANVAS_HEIGHT, ); - runtime.ctx.drawImage( - runtime.image, - 0, - 0, - PROJECTION_CANVAS_WIDTH, - PROJECTION_CANVAS_HEIGHT, + const fit = fitToProjection( + runtime.image.naturalWidth, + runtime.image.naturalHeight, ); + runtime.ctx.drawImage(runtime.image, fit.x, fit.y, fit.w, fit.h); runtime.canvasStamp = (runtime.canvasStamp || 0) + 1; runtime.lastPlaceholderPaintAt = 0; } From 548e7f23d118f82b66bbccb30fb2a9c81c4a8993 Mon Sep 17 00:00:00 2001 From: Daniel Slay Date: Sat, 17 Oct 2026 15:10:49 -0400 Subject: [PATCH 3/9] docs(changelog): credit the live camera color --- changelog.d/1200-cctv-live-icon-color.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/1200-cctv-live-icon-color.md diff --git a/changelog.d/1200-cctv-live-icon-color.md b/changelog.d/1200-cctv-live-icon-color.md new file mode 100644 index 000000000..5f39fad1f --- /dev/null +++ b/changelog.d/1200-cctv-live-icon-color.md @@ -0,0 +1,5 @@ +- Color CCTV cameras with live video violet. Cameras with an HLS, MP4 or WebM + feed were the same cyan as stills, so a live DelDOT or Statens vegvesen + camera could only be found by opening it. Stills stay cyan and the active + camera stays amber, and focus de-emphasis fades the violet the same way + (Danielslay86, #1200). From 5a87fb3b1525a07c578a7bec70c5f1674dd08a07 Mon Sep 17 00:00:00 2001 From: Daniel Slay Date: Sat, 17 Oct 2026 15:15:09 -0400 Subject: [PATCH 4/9] docs(changelog): credit the stills aspect fix --- changelog.d/1201-cctv-stills-aspect.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1201-cctv-stills-aspect.md diff --git a/changelog.d/1201-cctv-stills-aspect.md b/changelog.d/1201-cctv-stills-aspect.md new file mode 100644 index 000000000..fdb560064 --- /dev/null +++ b/changelog.d/1201-cctv-stills-aspect.md @@ -0,0 +1,4 @@ +- Draw CCTV stills at their source aspect on the monitor plane instead of + stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so + every still was widened; they are now centered and pillarboxed on the dark + the placeholder already ends on (Danielslay86, #1201). From 1959b33c486b5a4437554401fb0966fdb500d3b0 Mon Sep 17 00:00:00 2001 From: Daniel Slay Date: Mon, 19 Oct 2026 10:39:41 -0400 Subject: [PATCH 5/9] fix(cctv): test the still fit, redraw on size change, share the matte color --- scripts/format-scope.json | 1 + src/layers/cctv/frames.js | 29 +++++++++++++++++++------- src/layers/cctv/frames.test.mjs | 37 +++++++++++++++++++++++++++++++++ src/layers/cctv/policy.js | 3 +++ 4 files changed, 62 insertions(+), 8 deletions(-) create mode 100644 src/layers/cctv/frames.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5ec1adb0c..edd1c69af 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -555,6 +555,7 @@ "src/data/cctvNswSource.test.mjs", "src/data/cctvTxdotSource.test.mjs", "src/layers/cctv/source.test.mjs", + "src/layers/cctv/frames.test.mjs", "src/data/alprCameras.js", "src/data/alprCameras.test.mjs", "src/data/dataCredits.test.mjs", diff --git a/src/layers/cctv/frames.js b/src/layers/cctv/frames.js index 7e037f198..807770c10 100644 --- a/src/layers/cctv/frames.js +++ b/src/layers/cctv/frames.js @@ -3,6 +3,7 @@ import { FRAME_SIGNATURE_H, PROJECTION_CANVAS_WIDTH, PROJECTION_CANVAS_HEIGHT, + PROJECTION_MATTE_COLOR, PROJECTION_TEXTURE_SWAP_MS, ACTIVE_FRAME_REFRESH_MS, FRAME_ENDPOINT, @@ -12,7 +13,16 @@ import { PLACEHOLDER_REPAINT_MS, } from './policy.js'; -function fitToProjection(sourceW, sourceH) { +/** + * Contain-fits a source frame into the projection canvas: scaled to the + * largest size that fits, centered, with the margins left for the matte fill. + * Falls back to the full canvas when either dimension is missing or zero. + * + * @param {number} sourceW - Source width in pixels. + * @param {number} sourceH - Source height in pixels. + * @returns {{x:number,y:number,w:number,h:number}} Draw rectangle in canvas pixels. + */ +export function fitToProjection(sourceW, sourceH) { if (!sourceW || !sourceH) return { x: 0, @@ -204,7 +214,7 @@ export function createFrames({ state: layerState, services, parts, source }) { ctx.clearRect(0, 0, w, h); const g = ctx.createLinearGradient(0, 0, w, h); g.addColorStop(0, '#05111a'); - g.addColorStop(1, '#01070c'); + g.addColorStop(1, PROJECTION_MATTE_COLOR); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); @@ -342,7 +352,14 @@ export function createFrames({ state: layerState, services, parts, source }) { // base color (planeMaterial color = WHITE, alpha .95) for the frame or // two Cesium needs to rebind, which IS the periodic white flash from the // owner field tests (2026-07-04 and 2026-07-30). - const signature = projectionFrameSignature(runtime); + const fit = fitToProjection( + runtime.image.naturalWidth, + runtime.image.naturalHeight, + ); + // Pixels are hashed at a fixed size, so the fit carries the aspect. + const pixels = projectionFrameSignature(runtime); + const signature = + pixels === null ? null : `${pixels}:${fit.w}x${fit.h}`; runtime.drawnImageStamp = runtime.imageStamp; if (signature !== null && signature === runtime.lastFrameSignature) { // Identical pixels — leave the canvas, and therefore the bound @@ -350,17 +367,13 @@ export function createFrames({ state: layerState, services, parts, source }) { return; } runtime.lastFrameSignature = signature; - runtime.ctx.fillStyle = '#01070c'; + runtime.ctx.fillStyle = PROJECTION_MATTE_COLOR; runtime.ctx.fillRect( 0, 0, PROJECTION_CANVAS_WIDTH, PROJECTION_CANVAS_HEIGHT, ); - const fit = fitToProjection( - runtime.image.naturalWidth, - runtime.image.naturalHeight, - ); runtime.ctx.drawImage(runtime.image, fit.x, fit.y, fit.w, fit.h); runtime.canvasStamp = (runtime.canvasStamp || 0) + 1; runtime.lastPlaceholderPaintAt = 0; diff --git a/src/layers/cctv/frames.test.mjs b/src/layers/cctv/frames.test.mjs new file mode 100644 index 000000000..5f21e4abd --- /dev/null +++ b/src/layers/cctv/frames.test.mjs @@ -0,0 +1,37 @@ +// src/layers/cctv/frames.test.mjs +// Still-frame fit (#1201): stills draw at their source aspect, centered on the +// 1920x1080 projection canvas, instead of being stretched to fill it. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fitToProjection } from './frames.js'; + +const FULL_CANVAS = { x: 0, y: 0, w: 1920, h: 1080 }; + +test('a 4:3 still is pillarboxed at full height', () => { + assert.deepEqual(fitToProjection(640, 480), { + x: 240, + y: 0, + w: 1440, + h: 1080, + }); +}); + +test('a source wider than 16:9 is letterboxed at full width', () => { + assert.deepEqual(fitToProjection(3840, 1080), { + x: 0, + y: 270, + w: 1920, + h: 540, + }); +}); + +test('a 16:9 still fills the canvas', () => { + assert.deepEqual(fitToProjection(1920, 1080), FULL_CANVAS); +}); + +test('a missing or zero dimension falls back to the full canvas', () => { + assert.deepEqual(fitToProjection(0, 480), FULL_CANVAS); + assert.deepEqual(fitToProjection(640, 0), FULL_CANVAS); + assert.deepEqual(fitToProjection(640, undefined), FULL_CANVAS); + assert.deepEqual(fitToProjection(undefined, undefined), FULL_CANVAS); +}); diff --git a/src/layers/cctv/policy.js b/src/layers/cctv/policy.js index cf09ebe64..3cd4910e9 100644 --- a/src/layers/cctv/policy.js +++ b/src/layers/cctv/policy.js @@ -35,6 +35,9 @@ export const PROJECTION_CANVAS_WIDTH = 1920; export const PROJECTION_CANVAS_HEIGHT = 1080; +/** Matte behind fitted stills; the placeholder gradient's dark end. */ +export const PROJECTION_MATTE_COLOR = '#01070c'; + // Downsample grid for the unchanged-frame signature (drawProjectionFrame). // 64x36 keeps the 16:9 aspect and reads ~9 KB per check versus the 8.3 MB a // full-resolution compare would touch. From 8e635c9a3ab2d925b102d035fab786a632ad772c Mon Sep 17 00:00:00 2001 From: Daniel Slay Date: Mon, 19 Oct 2026 10:50:22 -0400 Subject: [PATCH 6/9] test(cctv): pin the camera icon colors; say video feed, not live --- changelog.d/1200-cctv-live-icon-color.md | 10 +++---- docs/CURRENT-STATE.md | 2 +- src/data/cctv.test.mjs | 36 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/changelog.d/1200-cctv-live-icon-color.md b/changelog.d/1200-cctv-live-icon-color.md index 5f39fad1f..c17c1760d 100644 --- a/changelog.d/1200-cctv-live-icon-color.md +++ b/changelog.d/1200-cctv-live-icon-color.md @@ -1,5 +1,5 @@ -- Color CCTV cameras with live video violet. Cameras with an HLS, MP4 or WebM - feed were the same cyan as stills, so a live DelDOT or Statens vegvesen - camera could only be found by opening it. Stills stay cyan and the active - camera stays amber, and focus de-emphasis fades the violet the same way - (Danielslay86, #1200). +- Color CCTV cameras with a video feed violet. Cameras with an HLS, MP4 or + WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen + video camera could only be found by opening it. Stills stay cyan and the + active camera stays amber, and focus de-emphasis fades the violet the same + way (Danielslay86, #1200). \ No newline at end of file diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 734e722d7..fb3291080 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4359,7 +4359,7 @@ silently demoting every later lookup for the session. ground clamp now lifts the cap _center_ only so the wireframe stays a true pyramid welded to the plane (was a flattened fan / the ~47.5 m divergence — RESOLVED), re-selecting the active camera is a no-op (killed a click-flash), and texture swaps gate on canvas content (killed a - periodic white flash). Camera icons are cyan for stills, violet for cameras with a live video feed, and amber for the active camera. Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES` + periodic white flash). Camera icons are cyan for stills, violet for cameras with a video feed, and amber for the active camera. Coverage is now **metro-wide: 250 cameras** (`CCTV_AUSTIN_MAX_SOURCES` default 36 → 250, hard bound 300), filtered to `camera_status === TURNED_ON` (~815 live of 1,003 rows). City packs (2026-07-04): Caltrans (districts 4/7/11/3 — SF, LA, San Diego, Sacramento; cap 300) and TfL London JamCams (cap 250) join Austin (cap 250) as keyless default diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index ca6a3c11a..e102b081d 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -77,6 +77,11 @@ import { CCTV_FOCUS_REQUEST_EVENT, activateCctvCameraFromWorldClick, } from '../cctvFocusRequest.js'; +import { + ACTIVE_CAMERA_COLOR, + IDLE_CAMERA_COLOR, + LIVE_CAMERA_COLOR, +} from '../layers/cctv/policy.js' const CCTV_PRESENTATION_SOURCE = fs.readFileSync( path.join( @@ -360,6 +365,37 @@ test('default COVERAGE refresh materializes the active and visible camera frustu } }); +test('camera icons are violet for video feeds, cyan for stills, amber when active', () => { + const record = (id, feedType) => ({ + camera: { ...UNCLAMPED_CAMERA, id, feedType, groundElevationM: 0 }, + billboard: {}, + coverageEntities: [], + projection: null, + }); + const idleVideo = record('idle-hls', 'hls'); + const idleStill = record('idle-image', 'image'); + const activeVideo = record('active-hls', 'hls'); + const viewer = { entities: { add: (entity) => entity } }; + + try { + // Disabled keeps the active video camera from opening a real projection; + // icon colors are assigned either way. + _setCctvCoverageStateForTest({ + viewer, + records: [idleVideo, idleStill, activeVideo], + activeCameraId: activeVideo.camera.id, + enabled: false, + coverageMode: 'off', + }); + refreshCoverageStyles(); + assert.equal(idleVideo.billboard.color, LIVE_CAMERA_COLOR); + assert.equal(idleStill.billboard.color, IDLE_CAMERA_COLOR); + assert.equal(activeVideo.billboard.color, ACTIVE_CAMERA_COLOR); + } finally { + _setCctvCoverageStateForTest({ enabled: false }); + } +}); + test('geometry drain pacing yields to tracked and cockpit camera ownership', () => { assert.deepEqual(cctvGeometryDrainPacing(), { batchSize: 4, delayMs: 120 }); assert.deepEqual( From d80683c44b9ea2eb8856b0f3fb8b0977a8e8b28f Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 16:05:38 +0100 Subject: [PATCH 7/9] docs(changelog): end the #1200 fragment with a newline Co-Authored-By: Claude Opus 5.5 --- changelog.d/1200-cctv-live-icon-color.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/1200-cctv-live-icon-color.md b/changelog.d/1200-cctv-live-icon-color.md index c17c1760d..30d67e1d4 100644 --- a/changelog.d/1200-cctv-live-icon-color.md +++ b/changelog.d/1200-cctv-live-icon-color.md @@ -2,4 +2,4 @@ WebM feed were the same cyan as stills, so a DelDOT or Statens vegvesen video camera could only be found by opening it. Stills stay cyan and the active camera stays amber, and focus de-emphasis fades the violet the same - way (Danielslay86, #1200). \ No newline at end of file + way (Danielslay86, #1200). From 35c612c5f6152476321f7a7abf4b9bd812fee184 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 16:50:21 +0100 Subject: [PATCH 8/9] test(cctv): lock the still draw path and fit-aware dedupe Drive drawProjectionFrame with a fake runtime and a recording canvas: a 640x480 still draws at its fitted rectangle over the matte, the same pixels at the same size skip the redraw, and the same pixels at a new aspect redraw at the new fit. The last case fails on the pixel-only signature; the first fails if drawImage stretches to the canvas again. Expected rectangles now read the canvas size from policy.js. Co-Authored-By: Claude Opus 5.5 --- src/layers/cctv/frames.test.mjs | 108 +++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/src/layers/cctv/frames.test.mjs b/src/layers/cctv/frames.test.mjs index 5f21e4abd..0d03e9457 100644 --- a/src/layers/cctv/frames.test.mjs +++ b/src/layers/cctv/frames.test.mjs @@ -3,9 +3,25 @@ // 1920x1080 projection canvas, instead of being stretched to fill it. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { fitToProjection } from './frames.js'; +import { createFrames, fitToProjection } from './frames.js'; +import { + FRAME_SIGNATURE_W, + FRAME_SIGNATURE_H, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + PROJECTION_MATTE_COLOR, +} from './policy.js'; -const FULL_CANVAS = { x: 0, y: 0, w: 1920, h: 1080 }; +// The expected rectangles below are written for a 1920x1080 canvas. +assert.equal(PROJECTION_CANVAS_WIDTH, 1920); +assert.equal(PROJECTION_CANVAS_HEIGHT, 1080); + +const FULL_CANVAS = { + x: 0, + y: 0, + w: PROJECTION_CANVAS_WIDTH, + h: PROJECTION_CANVAS_HEIGHT, +}; test('a 4:3 still is pillarboxed at full height', () => { assert.deepEqual(fitToProjection(640, 480), { @@ -35,3 +51,91 @@ test('a missing or zero dimension falls back to the full canvas', () => { assert.deepEqual(fitToProjection(640, undefined), FULL_CANVAS); assert.deepEqual(fitToProjection(undefined, undefined), FULL_CANVAS); }); + +// Draw path: the fit reaches drawImage, and the unchanged-frame skip keys on the +// fitted size as well as the pixels (the 64x36 hash alone drops aspect). +function stillHarness() { + const calls = []; + const ctx = { + fillStyle: null, + fillRect: (...rect) => calls.push(['fillRect', ctx.fillStyle, ...rect]), + clearRect: () => {}, + drawImage: (_image, ...rect) => calls.push(['drawImage', ...rect]), + }; + // Every frame downsamples to the same pixels, so only the size differs. + const pixels = new Uint8ClampedArray( + FRAME_SIGNATURE_W * FRAME_SIGNATURE_H * 4, + ).fill(7); + const runtime = { + mode: 'image', + ctx, + image: { naturalWidth: 0, naturalHeight: 0 }, + imageReady: true, + // In flight, so refreshProjectionImage returns before touching image.src. + imageLoading: true, + imageStamp: 0, + drawnImageStamp: -1, + lastFrameSignature: null, + canvasStamp: 0, + signatureCtx: { + clearRect: () => {}, + drawImage: () => {}, + getImageData: () => ({ data: pixels }), + }, + }; + const record = { camera: { id: 'still' }, projection: runtime }; + const { drawProjectionFrame } = createFrames({ + state: { _healthById: new Map(), _activeCameraId: null }, + services: {}, + parts: {}, + source: {}, + }); + const load = (width, height) => { + runtime.image = { naturalWidth: width, naturalHeight: height }; + runtime.imageStamp += 1; + calls.length = 0; + drawProjectionFrame(record); + return calls.slice(); + }; + return { runtime, load }; +} + +test('a still is drawn into its fitted rectangle over a full matte', () => { + const { runtime, load } = stillHarness(); + assert.deepEqual(load(640, 480), [ + [ + 'fillRect', + PROJECTION_MATTE_COLOR, + 0, + 0, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + ], + ['drawImage', 240, 0, 1440, 1080], + ]); + assert.equal(runtime.canvasStamp, 1); +}); + +test('same pixels at the same size skip the redraw', () => { + const { runtime, load } = stillHarness(); + load(640, 480); + assert.deepEqual(load(640, 480), []); + assert.equal(runtime.canvasStamp, 1); +}); + +test('same pixels at a new aspect redraw at the new fit', () => { + const { runtime, load } = stillHarness(); + load(640, 480); + assert.deepEqual(load(1920, 1080), [ + [ + 'fillRect', + PROJECTION_MATTE_COLOR, + 0, + 0, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + ], + ['drawImage', 0, 0, 1920, 1080], + ]); + assert.equal(runtime.canvasStamp, 2); +}); From 29fb756a5c4fcb590f20ca02da343129c03e97d2 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 16:50:21 +0100 Subject: [PATCH 9/9] test(cctv): cover the focus de-emphasis icon colors refreshCctvFocusStyles passes cameraColorFor as baseColorFor, but only refreshCoverageStyles was tested. The new rendering.test.mjs runs the focus pass through createRendering with stubbed focus services and checks that an idle video camera fades from violet, a still from cyan and the active camera from amber. It fails if the focus callback goes back to the old active/idle check. Also adds the missing semicolon on the policy.js import in cctv.test.mjs. Co-Authored-By: Claude Opus 5.5 --- scripts/format-scope.json | 1 + src/data/cctv.test.mjs | 2 +- src/layers/cctv/rendering.test.mjs | 73 ++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 src/layers/cctv/rendering.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 4230c23ae..2d2ddb361 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -557,6 +557,7 @@ "src/data/cctvNswSource.test.mjs", "src/data/cctvTxdotSource.test.mjs", "src/layers/cctv/source.test.mjs", + "src/layers/cctv/rendering.test.mjs", "src/data/alprCameras.js", "src/data/alprCameras.test.mjs", "src/data/dataCredits.test.mjs", diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index e102b081d..dafaeb5f6 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -81,7 +81,7 @@ import { ACTIVE_CAMERA_COLOR, IDLE_CAMERA_COLOR, LIVE_CAMERA_COLOR, -} from '../layers/cctv/policy.js' +} from '../layers/cctv/policy.js'; const CCTV_PRESENTATION_SOURCE = fs.readFileSync( path.join( diff --git a/src/layers/cctv/rendering.test.mjs b/src/layers/cctv/rendering.test.mjs new file mode 100644 index 000000000..c4b490dd2 --- /dev/null +++ b/src/layers/cctv/rendering.test.mjs @@ -0,0 +1,73 @@ +// src/layers/cctv/rendering.test.mjs +// Icon colors under focus de-emphasis (#1200): the focus pass fades each icon +// from the same base color the coverage refresh assigns, so a video-feed camera +// stays violet while it dims instead of falling back to the idle cyan. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRendering } from './rendering.js'; +import { + ACTIVE_CAMERA_COLOR, + IDLE_CAMERA_COLOR, + LIVE_CAMERA_COLOR, +} from './policy.js'; +import { isVideoFeedType } from '../../sources/cctvTypes.js'; + +const FOCUS_FACTOR = 0.5; + +function assertFadedFrom(actual, base, label) { + assert.ok(actual, `${label}: color written`); + assert.deepEqual( + [actual.red, actual.green, actual.blue], + [base.red, base.green, base.blue], + `${label}: hue`, + ); + assert.equal(actual.alpha, base.alpha * FOCUS_FACTOR, `${label}: alpha`); +} + +test('focus de-emphasis fades each icon from its own color', () => { + const record = (id, feedType) => ({ + camera: { id, feedType }, + // No position, so the pass skips screen projection; the focus stub below + // decides the factor on its own. + billboard: { show: true }, + }); + const idleVideo = record('idle-hls', 'hls'); + const idleStill = record('idle-image', 'image'); + const activeVideo = record('active-hls', 'hls'); + const state = { + _enabled: true, + _viewer: { scene: {}, camera: {} }, + _records: [idleVideo, idleStill, activeVideo], + _activeCameraId: activeVideo.camera.id, + _activeFocusStyleCount: 0, + _lastFocusStyleAt: -Infinity, + }; + const focus = { + focusNowMs: (nowMs) => nowMs, + getFocusTarget: () => ({}), + focusPassIsNeeded: () => true, + advanceSpriteFocus: () => ({ + factor: FOCUS_FACTOR, + active: true, + transitioning: false, + }), + focusAlphaNeedsWrite: () => true, + }; + const { refreshCctvFocusStyles } = createRendering({ + state, + services: { focus }, + parts: { model: { isVideoFeedType } }, + source: {}, + }); + + refreshCctvFocusStyles(1000); + + assertFadedFrom(idleVideo.billboard.color, LIVE_CAMERA_COLOR, 'idle video'); + assertFadedFrom(idleStill.billboard.color, IDLE_CAMERA_COLOR, 'idle still'); + assertFadedFrom( + activeVideo.billboard.color, + ACTIVE_CAMERA_COLOR, + 'active video', + ); + assert.equal(state._activeFocusStyleCount, 3); +});