import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js'; import { msg, t } from '../../i18n/index.js'; import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js'; import { headingHudToken, isHeadingEstimated } from './headingConfidence.js'; import { specSummary, specFovCapabilityToken, isDatasheetFov, } from './modelSpecs.js'; // The summary line names the CAL badge, the source kind and the view band in // the page language; the codes underneath stay English. const CAL_BADGE_LABELS = Object.freeze({ calibrated: msg('CALIBRATED'), curated: msg('CURATED'), 'raw-prior': msg('RAW PRIOR'), }); // The same source-kind words as the CCTV panel (src/ui/cctvFrames.js). const SOURCE_KIND_LABELS = Object.freeze({ snapshot: msg('SNAPSHOT'), live: msg('LIVE'), fallback: msg('FALLBACK'), upstream: msg('UPSTREAM'), synthetic: msg('SYNTHETIC'), configured: msg('CONFIGURED'), seed: msg('SEED'), image: msg('IMAGE'), unknown: msg('UNKNOWN'), }); /** CAL badge id ('raw-prior') as the summary shows it; unknown ids stay codes. */ export function summaryCalLabel(badge) { const key = String(badge || ''); return CAL_BADGE_LABELS[key] ? t(CAL_BADGE_LABELS[key]) : key.replace('-', ' ').toUpperCase(); } /** Source kind ('image', 'snapshot') for the summary; HLS, MJPEG… stay codes. */ export function summarySourceLabel(kind) { const key = String(kind || 'image').toLowerCase(); return SOURCE_KIND_LABELS[key] ? t(SOURCE_KIND_LABELS[key]) : key.toUpperCase(); } /** View band id ('street', 'city'…) as the HUD names it. */ export function summaryViewBandLabel(band) { switch (band) { case 'street': return t('STREET', { context: 'view band' }); case 'city': return t('CITY', { context: 'view band' }); case 'regional': return t('REGIONAL', { context: 'view band' }); case 'global': return t('GLOBAL', { context: 'view band' }); default: return String(band || '').toUpperCase(); } } export function createPresentation({ state: layerState, services, parts, source, }) { /** * Builds a single-line summary string for the active camera, including city, * heading, FOV, coverage area, overlap count, projection mode, alignment * confidence, source type, and view context. * @returns {string} Summary text separated by mid-dots. */ function buildSummaryText() { const active = parts.selection.getActiveRecord(); if (!active) { return layerState._records.length ? t( '{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE', { count: layerState._records.length, }, ) : t('No cameras available in catalog.'); } const area = parts.model.sectorAreaKm2( active.camera.rangeM, active.camera.fovDeg, ); const overlapCount = parts.geometry.coverageNeighborCount(active); const viewKey = parts.model.currentViewContext(); const viewBand = viewKey.split(':')[0] || 'global'; const health = layerState._healthById.get(active.camera.id) || null; const calBadge = parts.calibration.deriveCalBadge(active.camera); return [ t('{city} CCTV', { city: active.camera.city.toUpperCase(), }), `${active.camera.name.toUpperCase()}`, // A synthetic bearing (headingConfidence 'low', no human calibration) // is tagged so a hashed guess never reads as a surveyed facing (#639). headingHudToken(active.camera), // Identified hardware labels its FOV as datasheet-sourced (catalog.js) // only while no manual calibration offset has moved it. isDatasheetFov(active.camera) ? t('FOV {value}° (DATASHEET)', { value: Math.round(active.camera.fovDeg), }) : t('FOV {value}°', { value: Math.round(active.camera.fovDeg), }), // A PTZ/varifocal datasheet range is what the lens can do, not its // current zoom, so it is shown beside the estimate, never as it. specFovCapabilityToken(active.camera, (span) => t('SPEC FOV {span}° (CAPABILITY)', { span }), ), t('COVERAGE {value}km²', { value: area.toFixed(2) }), overlapCount > 0 ? t('OVERLAP {count} cams', { count: overlapCount }) : t('ISOLATED VIEW'), layerState._showProjection ? t('PROJ MONITOR') : t('PROJ OFF'), layerState._coverageMode === 'viewshed' ? t('VIEWSHED') : null, t('CAL {value}', { value: summaryCalLabel(calBadge) }), t('SRC {value}', { value: summarySourceLabel( health?.sourceKind || active.camera.feedType || 'image', ), }), t('{value} CONTEXT', { value: summaryViewBandLabel(viewBand) }), // Identified hardware: append the model's datasheet specs + attribution. active.camera.spec ? t('{spec} · SPECS: CCTV-DATABASE.COM', { spec: specSummary(active.camera.spec).toUpperCase(), }) : null, ] .filter(Boolean) .join(' · '); } /** * Builds a public-facing camera state object for UI consumption. * Includes all pose, calibration, CAL badge, projection, and feed metadata. * @param {Object} record - Camera record. * @param {string|null} [activeId=null] - Active camera ID for the `active` flag. * @returns {Object} Public camera state. */ function getPublicCameraState(record, activeId = null) { const resolvedActiveId = activeId || parts.selection.getActiveRecord()?.camera.id || null; const camera = record.camera; const health = layerState._healthById.get(camera.id) || null; const isActive = camera.id === resolvedActiveId; const refreshMs = isActive ? ACTIVE_FRAME_REFRESH_MS : IDLE_FRAME_REFRESH_MS; return { id: camera.id, name: camera.name, city: camera.city, provider: camera.provider, 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, // Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js). fovSource: isDatasheetFov(camera) ? 'datasheet' : camera.fovSource === 'datasheet' ? 'calibrated' : camera.fovSource || 'estimated', specFovMinDeg: camera.specFovMinDeg ?? null, specFovMaxDeg: camera.specFovMaxDeg ?? null, spec: camera.spec || null, rangeM: camera.rangeM, elevationM: camera.absoluteHeightM, mountHeightM: camera.mountHeightM, active: isActive, feedType: camera.feedType, isVideo: parts.model.isVideoFeedType(camera.feedType) && record.projection?.mode !== 'image', sourceKind: health?.sourceKind || camera.sourceKind || (camera.feedConfigured ? 'configured' : 'seed'), sourceStatus: health?.status || 'unknown', sourceMessage: health?.message || '', sourceLabel: health?.label || camera.provider || '', credit: camera.credit || '', calibration: { ...parts.calibration.normalizeCalibration(camera.calibration), }, // Save-gated persistence (design §3e): true while the live pose carries // edits that have not been SAVEd (or RESET). Drives the CAL · EDITED chip. calDirty: !!record.calDirty, // Deterministic QA seam: counts commit-grade anchor resolutions (E/N drag // release, numeric E/N edit, or reset), never transient gizmo moves. groundResolveCount: record.calibrationGroundResolveCount || 0, // Per-record QA seam for proving transient gizmo moves never enter the // shared mesh-floor sampler while unrelated catalog cells finish. groundMeshSampleRequestCount: record.groundMeshSampleRequestCount || 0, // Datum QA seam: expose the immutable Re:Earth ellipsoidal prior // separately from the currently applied frustum ground. Google-3D may // legitimately refine the latter to the rendered mesh, so callers must // not infer the prior by subtracting mount height from live geometry. groundPriorM: Number.isFinite(record.groundPrior?.ellipsoid) ? record.groundPrior.ellipsoid : null, intrinsics: camera.intrinsics ? { ...camera.intrinsics } : null, extrinsics: camera.extrinsics ? { ...camera.extrinsics } : null, anchor: camera.anchor ? { ...camera.anchor } : null, // Panel-only trust signal (design §3b, amended by LOCKED §9.2/§9.3): no // in-world rendering reads this, no score-based quality math backs it. calBadge: parts.calibration.deriveCalBadge(camera), poseSource: camera.poseSource || null, basePose: camera.basePose ? { ...camera.basePose } : null, frameUrl: parts.frames.frameUrlFor(camera, refreshMs), mediaUrl: parts.frames.mediaUrlFor(camera), }; } /** * Assembles the full UI state payload containing layer toggles, camera list, * active camera details, summary text, and error state. * @returns {Object} Complete UI state for subscribers. */ function uiState() { const active = parts.selection.getActiveRecord(); const activeId = active?.camera.id || null; const payload = { enabled: layerState._enabled, // Compat boolean + the full tri-state (viewshed design §3b). showCoverage: layerState._coverageMode !== 'off', coverageMode: layerState._coverageMode, showProjection: layerState._showProjection, calibrationMode: layerState._calibrationMode, autoHop: layerState._autoHop, autoHopSuspended: layerState._autoHopSuspended, autoHopSec: layerState._autoHopSec, count: layerState._count, lastUpdate: layerState._lastUpdate, error: layerState._lastError, loading: { active: layerState._geoLoading, loaded: Math.min(layerState._geoLoadDone, layerState._geoLoadTotal), total: layerState._geoLoadTotal, }, // Ambient card tier telemetry (QA harnesses assert the fetch pacing — // minFrameFetchSpacingMs reads together with fetchMode: cold-fill bursts // legitimately reach ~250 ms, steady state stays >=1000 ms). ambientCards: { count: layerState._cardIds.size, limit: CCTV_AMBIENT_CARD_MAX, frameFetches: layerState._cardFetchCount, minFrameFetchSpacingMs: layerState._cardMinFetchSpacingMs, fetchMode: layerState._cardFetchMode, fetchesInFlight: layerState._cardFetchInFlightCount, // Item B QA seam: the hover-summoned pinned card, if any. hoverId: layerState._hoverCardId, }, activeCameraId: activeId, activeCamera: active ? getPublicCameraState(active, activeId) : null, cameras: layerState._records.map((record) => getPublicCameraState(record, activeId), ), summary: buildSummaryText(), }; return payload; } /** Dispatches the current UI state to all registered subscriber callbacks. */ function notifyListeners() { const payload = uiState(); for (const callback of layerState._listeners) { try { callback(payload); } catch (error) { console.warn('[Data:CCTV] listener error:', error); } } } /** * Throttled notifyListeners for transient (mid-drag) calibration patches — * the panel re-render is DOM-heavy, so live gizmo drags publish state at * ≤10 Hz while the in-world geometry still tracks every processed move. */ function notifyListenersThrottled() { const now = Date.now(); if (now - layerState._lastTransientNotifyAt < 100) return; layerState._lastTransientNotifyAt = now; notifyListeners(); } return { buildSummaryText, getPublicCameraState, uiState, notifyListeners, notifyListenersThrottled, }; }