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..30d67e1d4 --- /dev/null +++ b/changelog.d/1200-cctv-live-icon-color.md @@ -0,0 +1,5 @@ +- 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). 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). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 045d6e26b..9cafb15bf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4417,7 +4417,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 @@ -4435,7 +4435,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 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/scripts/format-scope.json b/scripts/format-scope.json index 2c45c0380..2c5009537 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -557,6 +557,8 @@ "src/data/cctvNswSource.test.mjs", "src/data/cctvTxdotSource.test.mjs", "src/layers/cctv/source.test.mjs", + "src/layers/cctv/frames.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 ca6a3c11a..dafaeb5f6 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( diff --git a/src/layers/cctv/frames.js b/src/layers/cctv/frames.js index d137b74b2..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,6 +13,37 @@ import { PLACEHOLDER_REPAINT_MS, } from './policy.js'; +/** + * 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, + 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 @@ -182,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); @@ -320,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 @@ -328,19 +367,14 @@ export function createFrames({ state: layerState, services, parts, source }) { return; } runtime.lastFrameSignature = signature; - runtime.ctx.clearRect( - 0, - 0, - PROJECTION_CANVAS_WIDTH, - PROJECTION_CANVAS_HEIGHT, - ); - runtime.ctx.drawImage( - runtime.image, + runtime.ctx.fillStyle = PROJECTION_MATTE_COLOR; + runtime.ctx.fillRect( 0, 0, PROJECTION_CANVAS_WIDTH, PROJECTION_CANVAS_HEIGHT, ); + 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..0d03e9457 --- /dev/null +++ b/src/layers/cctv/frames.test.mjs @@ -0,0 +1,141 @@ +// 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 { createFrames, fitToProjection } from './frames.js'; +import { + FRAME_SIGNATURE_W, + FRAME_SIGNATURE_H, + PROJECTION_CANVAS_WIDTH, + PROJECTION_CANVAS_HEIGHT, + PROJECTION_MATTE_COLOR, +} from './policy.js'; + +// 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), { + 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); +}); + +// 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); +}); diff --git a/src/layers/cctv/policy.js b/src/layers/cctv/policy.js index cf09ebe64..96d7985f1 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. @@ -410,6 +413,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. 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); +});