diff --git a/CHANGELOG.md b/CHANGELOG.md index 834650d63..81ff88aa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,11 @@ - CCTV cameras with identified hardware show the model's datasheet specs. When a source names a camera model found in the vendored CCTV Camera Database records (CC0), the HUD meta line adds its resolution, night vision and PTZ capability. - A fixed single-lens model's horizontal FOV replaces the estimate - (`FOV n° (DATASHEET)`); a PTZ or varifocal range is what the lens can do, not - its current zoom, so those cameras keep the estimate and show - `SPEC FOV min–max° (CAPABILITY)` beside it (bassem chagra, #452). + A fixed single-lens model's horizontal FOV fills in when the source states no + FOV and the pose is not curated (`FOV n° (DATASHEET)`); a PTZ or varifocal + range is what the lens can do, not its current zoom, so those cameras keep the + estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it (bassem chagra, + #452). - Add WSDOT highway cameras (Washington State) as a keyless CCTV source pack: WSDOT's official Travel Center camera layer, with frames registered only on diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 8f3b55dbf..d1ecd1423 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -185,6 +185,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) | | **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — | +| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) | ### ⚠️ TeleGeography is bundled but NonCommercial @@ -210,6 +211,17 @@ commercial use with attribution and applicable database share-alike/access obligations; it does not license the application code. Trimmed test-only tile fixtures retain their provenance in `src/data/fixtures/README.md`. +### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`) + +The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0) of the +[CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of +camera models transcribed from manufacturer datasheets, dedicated to the public domain under +**CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King +County, Sarasota County and Sioux Falls) are bundled, compiled into the module. Nothing is +fetched at runtime. The records describe camera products (optics, resolution, night vision) +only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD +credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders. + ### Bundled OpenStreetMap data Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code: [datacenters](src/data/local_data/datacenters/), [dams](src/data/local_data/dams/) (via Open Infrastructure Map), [military names](src/data/local_data/osm_military_names/), and [OSM test fixtures](src/data/fixtures/). Keep © OpenStreetMap contributors and offer modified databases under ODbL. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d24d9d92d..b2be525c7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4185,7 +4185,8 @@ silently demoting every later lookup for the session. - **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens - hardware replaces the estimated FOV, shown as `FOV n° (DATASHEET)`. PTZ, varifocal and + hardware fills the FOV, and only when the source or seed states none and the pose is not + curated, shown as `FOV n° (DATASHEET)` until a manual calibration moves it. PTZ, varifocal and multi-sensor datasheets publish a capability range, not the lens's current zoom, so those cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452). diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index 7804327d6..900692939 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -7,17 +7,22 @@ import { /** * Applies datasheet enrichment to a camera in place: when it names a known - * hardware `model`, the spec record is attached for the HUD and the - * datasheet's horizontal FOV range rides along as `specFovMinDeg`/ - * `specFovMaxDeg`. Only fixed-lens hardware replaces the pose estimate: a - * PTZ/varifocal range says what the lens CAN do, not where it is zoomed now. + * hardware `model` (and, when published, a matching `manufacturer`), the spec + * record is attached for the HUD and the datasheet's horizontal FOV range + * rides along as `specFovMinDeg`/`specFovMaxDeg`. Only fixed-lens hardware + * fills the FOV, and only when none was given: an explicit source/seed + * `fovDeg` or a curated pose is never overwritten. A PTZ/varifocal range says + * what the lens CAN do, not where it is zoomed now. * Unknown models — the common case — change nothing. * @param {Object} camera - Camera being built (mutated). - * @param {string} [modelName] - Hardware model from a source or seed. + * @param {{model?:string, manufacturer?:string, explicitFov?:boolean}} hardware + * - Hardware model/manufacturer from a source or seed, and whether that + * source or seed stated its own `fovDeg`. * @param {(v:number,min:number,max:number)=>number} clamp - Pose clamp helper. */ -function applyModelEnrichment(camera, modelName, clamp) { - const spec = modelName ? lookupModelSpec(modelName) : null; +function applyModelEnrichment(camera, hardware, clamp) { + const { model, manufacturer, explicitFov } = hardware; + const spec = model ? lookupModelSpec(model, manufacturer) : null; if (!spec) return; const range = horizontalFovRangeDeg(spec); if (range) { @@ -25,7 +30,7 @@ function applyModelEnrichment(camera, modelName, clamp) { camera.specFovMaxDeg = range.maxDeg; } const fixedFov = fixedHorizontalFovDeg(spec); - if (fixedFov) { + if (fixedFov && !explicitFov && camera.poseSource !== 'curated') { camera.fovDeg = clamp(fixedFov, 20, 125); camera.fovSource = 'datasheet'; } @@ -77,7 +82,11 @@ export function createCatalog({ state: layerState, services, parts, source }) { parts.model.clamp(seed.elevationM ?? 22, 8, 80), pitchDeg: parts.model.clamp(seed.pitchDeg ?? -17, -40, -4), }; - applyModelEnrichment(camera, seed.model, parts.model.clamp); + applyModelEnrichment( + camera, + { model: seed.model, explicitFov: seed.fovDeg != null }, + parts.model.clamp, + ); parts.model.ensureCameraPose(camera); catalog.push(camera); } @@ -242,7 +251,15 @@ export function createCatalog({ state: layerState, services, parts, source }) { }; applyModelEnrichment( camera, - source.model || seed?.model, + { + model: source.model || seed?.model, + manufacturer: source.model ? source.manufacturer : '', + explicitFov: + (source.fovDeg != null && Number.isFinite(Number(source.fovDeg))) || + CAMERA_SEEDS.some( + (entry) => entry.id === id && entry.fovDeg != null, + ), + }, parts.model.clamp, ); parts.model.ensureCameraPose(camera); diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index cee3e5044..1e5bc7cf1 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -14,7 +14,7 @@ * * Spec records are vendored verbatim from the CCTV Camera Database * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset - * of 28,000+ camera models transcribed from manufacturer datasheets (75% with + * of 28,000+ camera models transcribed from manufacturer datasheets (84% with * FOV, as of v2.26.0). This module vendors the records that the currently integrated public * catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis * Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that @@ -25,7 +25,7 @@ * night vision) — never endpoints, stream paths, or access/config data. */ -/** Verbatim records from the dataset's model-enrichment extract (v2.19.0). */ +/** Verbatim records from the dataset's model-enrichment extract (v2.26.0). */ const RECORDS = [ { id: 'axis-q6135-le', @@ -307,74 +307,144 @@ function normalize(value) { .replace(/\s+/g, ' '); } -const BY_MODEL = new Map(); -for (const record of RECORDS) { - BY_MODEL.set(normalize(record.model), record); - for (const alias of record.aliases || []) - BY_MODEL.set(normalize(alias), record); +/** + * Whitespace words of a name, lowercased, with surrounding punctuation trimmed + * so "(NDP-7512-Z30)" reads as "ndp-7512-z30". Inner hyphens and dots stay: + * "f4105-lre-unknown" is one word, never "f4105-lre" plus noise. + * @param {string} value + * @returns {string[]} + */ +function words(value) { + return normalize(value) + .split(' ') + .map((raw) => raw.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, '')) + .filter(Boolean); } /** - * DISTINCTIVE tokens of a record — the whitespace words of its model and - * aliases that identify hardware rather than describe it. A token qualifies - * only when it is >= 4 chars AND contains a digit or hyphen, so generic words - * ("dome", "network", "camera", "series", "ptz") are never distinctive and a - * verbose registry string cannot match on them alone. Surrounding punctuation - * is trimmed so "(NDP-7512-Z30)" indexes as "ndp-7512-z30". - * @param {Object} record - * @returns {string[]} Unique distinctive tokens, lowercased. + * Descriptor words that never identify hardware: product-type nouns, codec + * and resolution/zoom/frequency tokens ("h.264", "2mp", "40x", "1080p"). */ -function distinctiveTokens(record) { - const out = new Set(); - for (const name of [record.model, ...(record.aliases || [])]) { - for (const raw of normalize(name).split(' ')) { - const token = raw.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''); - if (token.length >= 4 && /[0-9-]/.test(token)) out.add(token); - } +const GENERIC_WORDS = new Set([ + 'network', + 'camera', + 'cam', + 'ip', + 'ptz', + 'dome', + 'bullet', + 'box', + 'hd', + 'ir', + 'outdoor', + 'indoor', + 'series', +]); + +function isGeneric(word) { + return ( + GENERIC_WORDS.has(word) || + /^h\.?\d+$/.test(word) || + /^\d+(?:\.\d+)?(?:mp|p|k|x|mm|hz|tvl)$/.test(word) + ); +} + +/** A model code: letters AND digits, and not a generic descriptor. */ +function isModelCode(word) { + return /[a-z]/.test(word) && /\d/.test(word) && !isGeneric(word); +} + +/** Hyphenated variant suffixes that do not change the hardware. */ +const VARIANT_SUFFIXES = new Set(['50hz', '60hz', 'ntsc', 'pal']); + +/** "q6155-e-60hz" → "q6155-e"; "f4105-lre-unknown" is left as is. */ +function stripVariantSuffix(word) { + let out = word; + for (;;) { + const cut = out.lastIndexOf('-'); + if (cut <= 0 || !VARIANT_SUFFIXES.has(out.slice(cut + 1))) return out; + out = out.slice(0, cut); } - return [...out]; +} + +/** Manufacturer names (lowercased words) accepted for each record brand. */ +const BRAND_ALIASES = { + Hanwha: ['wisenet', 'samsung'], + Hikvision: ['hiwatch'], +}; + +function brandWords(record) { + return new Set([ + ...words(record.brand), + ...(BRAND_ALIASES[record.brand] || []), + ]); } /** - * Token index for the substring fallback: one precompiled whole-word matcher - * per distinctive token. Word boundaries are any non-alphanumeric char (or the - * string ends), so hyphenated/dotted tokens like "q6155-e" or "h.264" match as - * whole words while "700" never matches inside "7000". + * One entry per record name (model + aliases). `required` is every + * non-generic word of the name — all must appear in a query, so a generation + * or suffix ("Mk II", "7000i" vs "7000") is never dropped. `hasCode` marks + * names carrying a letter+digit model code; names without one ("3950", + * "01959-004", "AUTODOME 800 Series") match only when the brand does. */ -const TOKEN_INDEX = RECORDS.flatMap((record) => - distinctiveTokens(record).map((token) => ({ - token, - record, - re: new RegExp( - `(?:^|[^a-z0-9])${token.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}(?:[^a-z0-9]|$)`, - ), - })), -); +const NAME_INDEX = RECORDS.flatMap((record) => { + const brand = brandWords(record); + return [record.model, ...(record.aliases || [])].map((name) => { + const nameWords = words(name); + return { + record, + brand, + allowed: new Set(nameWords), + required: nameWords.filter((word) => !isGeneric(word)), + hasCode: nameWords.some(isModelCode), + }; + }); +}); /** * Looks up a spec record for a hardware-model string. * - * Exact match first (case-insensitive, whitespace-collapsed) on the model and - * every alias. When that misses, a token fallback lets a VERBOSE registry - * string resolve — e.g. "AXIS Q6155-E PTZ Dome Network Camera" finds the - * Q6155-E via its distinctive "q6155-e" token. The most specific (longest) - * matched token wins; if two different records tie on the longest matched - * token the result is ambiguous and returns null (never guesses). + * A record name matches when every non-generic word of that name appears in + * the query (case-insensitive, punctuation-trimmed) and every other query word + * is generic or the record's brand. That lets a VERBOSE registry string + * resolve — "AXIS Q6155-E PTZ Dome Network Camera" finds the Q6155-E — while + * an unrelated code ("Q1615"), a generation suffix ("Mk II") or a longer part + * number ("F4105-LRE-UNKNOWN") rejects the match. Codec, resolution and + * product-type words never identify hardware on their own. A name with no + * letter+digit model code ("3950") needs the brand, from `manufacturer` or the + * query itself. A `manufacturer` that is not the record's brand always + * rejects. Matches on two different records are ambiguous → null (never + * guesses). * @param {string} model - Hardware model as named by a source pack or seed. + * @param {string} [manufacturer] - The source's manufacturer, when published. * @returns {Object|null} The verbatim spec record, or null when unknown. */ -export function lookupModelSpec(model) { - if (!model) return null; - const query = normalize(model); - if (!query) return null; - const exact = BY_MODEL.get(query); - if (exact) return exact; - const matches = TOKEN_INDEX.filter((entry) => entry.re.test(query)); - if (!matches.length) return null; - const maxLen = Math.max(...matches.map((m) => m.token.length)); - const top = matches.filter((m) => m.token.length === maxLen); - const records = new Set(top.map((m) => m.record)); - return records.size === 1 ? top[0].record : null; +export function lookupModelSpec(model, manufacturer) { + const query = words(model).map(stripVariantSuffix); + if (!query.length) return null; + const maker = words(manufacturer); + const found = new Set(); + for (const entry of NAME_INDEX) { + const makerMatches = maker.some((word) => entry.brand.has(word)); + if (maker.length && !makerMatches) continue; + if ( + !entry.hasCode && + !makerMatches && + !query.some((word) => entry.brand.has(word)) + ) + continue; + if (!entry.required.length) continue; + if (!entry.required.every((word) => query.includes(word))) continue; + if ( + !query.every( + (word) => + entry.allowed.has(word) || entry.brand.has(word) || isGeneric(word), + ) + ) + continue; + found.add(entry.record); + } + return found.size === 1 ? [...found][0] : null; } /** @@ -416,6 +486,16 @@ export function fixedHorizontalFovDeg(spec) { return range && range.minDeg === range.maxDeg ? range.minDeg : null; } +/** + * True when the camera's CURRENT FOV is the datasheet figure: it was filled + * from a fixed-lens datasheet and no manual calibration offset has moved it. + * @param {Object} camera - Catalog camera (see applyModelEnrichment). + * @returns {boolean} + */ +export function isDatasheetFov(camera) { + return camera?.fovSource === 'datasheet' && !camera.calibration?.fovDeg; +} + /** * HUD token for a datasheet FOV that describes capability rather than the * camera's current state, e.g. 'SPEC FOV 2.4–58° (CAPABILITY)'. diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index 06ffe3a64..9fbdb00dd 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -9,6 +9,7 @@ import { horizontalFovRangeDeg, fixedHorizontalFovDeg, specFovCapabilityToken, + isDatasheetFov, specSummary, } from './modelSpecs.js'; import { createCatalog } from './catalog.js'; @@ -21,7 +22,8 @@ test('lookupModelSpec matches case-insensitively and trims', () => { }); test('lookupModelSpec matches aliases', () => { - const byAlias = lookupModelSpec('01959-004'); + // A part number without letters needs the brand. + const byAlias = lookupModelSpec('01959-004', 'Axis'); assert.ok(byAlias); assert.equal(byAlias.model, 'Q6135-LE'); }); @@ -29,7 +31,8 @@ test('lookupModelSpec matches aliases', () => { test('vendored table covers the integrated catalogs published hardware', () => { // Models the live packs publish: King County (Cohu, AUTODOME IP 5000i), // Sarasota (Bosch AUTODOME 7000/VG4), Sioux Falls (Axis). - const cohu = lookupModelSpec('3950'); // King County publishes "3950" + // King County publishes "3950" with Manufacturer "Cohu". + const cohu = lookupModelSpec('3950', 'Cohu'); assert.equal(cohu?.brand, 'Cohu'); const autodome = lookupModelSpec('AUTODOME IP 5000i'); assert.ok(autodome && horizontalFovRangeDeg(autodome).maxDeg > 0); @@ -71,8 +74,8 @@ test('token fallback never matches on generic words or wrong models', () => { }); test('exact match still wins over the token fallback', () => { - // "3965" is an alias of the 3960; exact alias match, not a token scan. - assert.equal(lookupModelSpec('3965')?.id, 'cohu-3960'); + // "3965" is an alias of the 3960, not the 3950. + assert.equal(lookupModelSpec('3965', 'Cohu')?.id, 'cohu-3960'); assert.equal(lookupModelSpec('AUTODOME IP 5000i')?.id, 'bosch-ndp-5502-30'); }); @@ -167,10 +170,10 @@ function browserCatalog() { }); } -test('catalog keeps the estimated FOV for PTZ hardware and substitutes only fixed-lens datasheets', () => { +test('catalog keeps the estimated FOV for PTZ hardware and fills only fixed-lens datasheets', () => { const [ptz, fixed, unknown] = browserCatalog().buildCatalogFromSources([ { id: 'ptz', lat: 47.7, lon: -122, fovDeg: 44, model: 'Q6135-LE' }, - { id: 'fixed', lat: 47.7, lon: -122, fovDeg: 44, model: 'F4105-LRE' }, + { id: 'fixed', lat: 47.7, lon: -122, model: 'F4105-LRE' }, { id: 'unknown', lat: 47.7, lon: -122, fovDeg: 44, model: 'NOPE-1' }, ]); @@ -191,6 +194,99 @@ test('catalog keeps the estimated FOV for PTZ hardware and substitutes only fixe assert.equal(unknown.specFovMaxDeg, undefined); }); +test('codec, format and product words never identify hardware', () => { + // "h.264" is in the VG4 AUTODOME alias; it is a codec, not a model code. + assert.equal(lookupModelSpec('Axis Q1615 H.264 Network Camera'), null); + assert.equal(lookupModelSpec('H.264 Network Camera'), null); + assert.equal(lookupModelSpec('H.265 IP Camera'), null); +}); + +test('generation and suffix distinctions are preserved', () => { + // A Mk II is not the vendored (Mk I) Q6135-LE. + assert.equal(lookupModelSpec('AXIS Q6135-LE Mk II'), null); + // A longer part number is a different product... + assert.equal(lookupModelSpec('F4105-LRE-UNKNOWN'), null); + // ...unless the rest is a mains-frequency variant suffix. + assert.equal(lookupModelSpec('Q6155-E-60Hz')?.id, 'axis-q6155-e'); +}); + +test('manufacturer must agree with the record brand', () => { + // Purely numeric models need a brand match. + assert.equal(lookupModelSpec('3950'), null); + assert.equal(lookupModelSpec('Pelco 3950'), null); + assert.equal(lookupModelSpec('3950', 'Pelco'), null); + // A model code under the wrong manufacturer is not that record either. + assert.equal(lookupModelSpec('Q6135-LE', 'Hikvision'), null); + // Case-insensitive, with company suffixes and aliases. + assert.equal(lookupModelSpec('3950', 'COHU')?.id, 'cohu-3950-iview'); + assert.equal( + lookupModelSpec('Q6135-LE', 'Axis Communications AB')?.id, + 'axis-q6135-le', + ); + assert.equal( + lookupModelSpec('XNP-6400RW', 'Hanwha Vision')?.id, + 'hanwha-xnp-6400rw', + ); + assert.equal( + lookupModelSpec('XNP-6400RW', 'Wisenet')?.id, + 'hanwha-xnp-6400rw', + ); +}); + +test('King County snapshot model strings still resolve', () => { + // Model/Manufacturer pairs as published in the King County feed. + assert.equal(lookupModelSpec('3950', 'Cohu')?.id, 'cohu-3950-iview'); + assert.equal(lookupModelSpec('3965', 'Cohu')?.id, 'cohu-3960'); + assert.equal(lookupModelSpec('3925 Dome', 'Cohu')?.id, 'cohu-3920-idome'); + assert.equal(lookupModelSpec('Q6135-LE', 'Axis')?.id, 'axis-q6135-le'); + assert.equal( + lookupModelSpec('AUTODOME IP 5000i', 'BOSCH')?.id, + 'bosch-ndp-5502-30', + ); +}); + +test('catalog never overwrites an explicit FOV or a curated pose', () => { + const [explicit, curated, cohu, pelco] = + browserCatalog().buildCatalogFromSources([ + { id: 'explicit', lat: 47.7, lon: -122, fovDeg: 44, model: 'F4105-LRE' }, + { + id: 'curated', + lat: 47.7, + lon: -122, + model: 'F4105-LRE', + poseSource: 'curated', + }, + { id: 'cohu', lat: 47.7, lon: -122, model: '3950', manufacturer: 'Cohu' }, + { + id: 'pelco', + lat: 47.7, + lon: -122, + model: '3950', + manufacturer: 'Pelco', + }, + ]); + assert.equal(explicit.fovDeg, 44); + assert.equal(explicit.fovSource, undefined); + assert.equal(explicit.spec.model, 'F4105-LRE'); + assert.equal(curated.fovDeg, 74); + assert.equal(curated.fovSource, undefined); + // The source's manufacturer reaches the lookup. + assert.equal(cohu.spec?.id, 'cohu-3950-iview'); + assert.equal(pelco.spec, undefined); +}); + +test('the DATASHEET label drops once calibration moves the FOV', () => { + const camera = { fovSource: 'datasheet', fovDeg: 110 }; + assert.equal(isDatasheetFov(camera), true); + assert.equal(isDatasheetFov({ ...camera, calibration: { fovDeg: 0 } }), true); + assert.equal( + isDatasheetFov({ ...camera, calibration: { fovDeg: 6 } }), + false, + ); + assert.equal(isDatasheetFov({ fovDeg: 74 }), false); + assert.equal(isDatasheetFov(null), false); +}); + test('specSummary renders the HUD line and degrades by omission', () => { assert.equal( specSummary(lookupModelSpec('Q6135-LE')), diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index efa18e462..34c7dbcc9 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -1,7 +1,11 @@ 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'; -import { specSummary, specFovCapabilityToken } from './modelSpecs.js'; +import { + specSummary, + specFovCapabilityToken, + isDatasheetFov, +} from './modelSpecs.js'; export function createPresentation({ state: layerState, @@ -40,8 +44,9 @@ export function createPresentation({ // 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). - `FOV ${Math.round(active.camera.fovDeg)}°${active.camera.fovSource === 'datasheet' ? ' (DATASHEET)' : ''}`, + // Identified hardware labels its FOV as datasheet-sourced (catalog.js) + // only while no manual calibration offset has moved it. + `FOV ${Math.round(active.camera.fovDeg)}°${isDatasheetFov(active.camera) ? ' (DATASHEET)' : ''}`, // 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), @@ -96,7 +101,11 @@ export function createPresentation({ pitchDeg: camera.pitchDeg, fovDeg: camera.fovDeg, // Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js). - fovSource: camera.fovSource || 'estimated', + fovSource: isDatasheetFov(camera) + ? 'datasheet' + : camera.fovSource === 'datasheet' + ? 'calibrated' + : camera.fovSource || 'estimated', specFovMinDeg: camera.specFovMinDeg ?? null, specFovMaxDeg: camera.specFovMaxDeg ?? null, spec: camera.spec || null,