From 2dd96e1f25f37a1464cf84ddbd162c677e546f87 Mon Sep 17 00:00:00 2001 From: MiguelGFerreira Date: Sat, 12 Sep 2026 14:24:33 -0300 Subject: [PATCH 01/76] =?UTF-8?q?feat(data):=20add=20seven=20Brazilian=20B?= =?UTF-8?q?ike=20Ita=C3=BA=20cities=20to=20the=20GBFS=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and Curitiba run on the same PBSC platform as the existing Austin, Honolulu and Chattanooga entries, so they need no new feed handling and no proxy allowlist change — isAllowedGbfsHost already accepts *.publicbikesystem.net. These are the layer's first stations outside the United States: 1,017 stations across the seven systems. --- CHANGELOG.md | 4 ++ DATA_SOURCES.md | 2 +- src/data/dataCredits.js | 2 +- src/layers/bikeshare/registry.js | 84 ++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f8245b4..3c451b4f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,10 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Add seven Brazilian Bike Itaú (Tembici) cities to the GBFS bikeshare registry: + São Paulo, Rio de Janeiro, Salvador, Recife, Porto Alegre, Brasília and + Curitiba. These are the layer's first stations outside the United States. + - Separate radio directory loading, station selection, globe presentation and playback into composed components with an explicit metadata source. - Separate submarine cable sources and rendering components, and export bundled geography lookup modules. diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 650d3fe6b..2dc8ba0d7 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -35,7 +35,7 @@ How to read this: | **City of Austin Open Data** | CCTV camera catalog + frames | City of Austin Open Data Terms of Use | "City of Austin, TX — data.austintexas.gov" | | **Caltrans (cwwp2.dot.ca.gov)** | CCTV camera catalogs + frames, California districts | Public Caltrans traffic camera data | "Caltrans — cwwp2.dot.ca.gov" (courtesy) | | **TfL Open Data (JamCams)** | CCTV camera catalog + frames, London | [TfL Open Data terms](https://tfl.gov.uk/info-for/open-data-users/) — attribution REQUIRED | "Powered by TfL Open Data. Contains OS data © Crown copyright and database rights" | -| **GBFS (Lyft / BCycle)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle) + its `license_url` | +| **GBFS (Lyft / BCycle / PBSC)** | Bikeshare availability | Per-feed (attribution-only) | Credit the operator (e.g. Austin BCycle, Bike Itaú/Tembici) + its `license_url` | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index 009ff6d01..817261608 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -137,7 +137,7 @@ export const DATA_CREDITS = [ }, { key: 'gbfs', - html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)', + html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle, Bike Itaú/Tembici)', }, { key: 'radio-browser', diff --git a/src/layers/bikeshare/registry.js b/src/layers/bikeshare/registry.js index ff752f664..a5a1ee32c 100644 --- a/src/layers/bikeshare/registry.js +++ b/src/layers/bikeshare/registry.js @@ -167,6 +167,90 @@ const RAW_GBFS_CITY_REGISTRY = [ 'https://chat.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', provider: 'Bike Chattanooga', }, + { + id: 'sao-paulo-bike-itau', + city: 'São Paulo, SP', + centerLat: -23.5505, + centerLon: -46.6333, + loadRadiusKm: 100, + stationInformationUrl: + 'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://saopaulo.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'rio-de-janeiro-bike-itau', + city: 'Rio de Janeiro, RJ', + centerLat: -22.9068, + centerLon: -43.1729, + loadRadiusKm: 100, + stationInformationUrl: + 'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://riodejaneiro.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'salvador-bike-itau', + city: 'Salvador, BA', + centerLat: -12.9777, + centerLon: -38.5016, + loadRadiusKm: 90, + stationInformationUrl: + 'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://salvador.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'recife-bike-itau', + city: 'Recife, PE', + centerLat: -8.0476, + centerLon: -34.877, + loadRadiusKm: 90, + stationInformationUrl: + 'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://recife.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'porto-alegre-bike-itau', + city: 'Porto Alegre, RS', + centerLat: -30.0346, + centerLon: -51.2177, + loadRadiusKm: 90, + stationInformationUrl: + 'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://portoalegre.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'brasilia-bike-itau', + city: 'Brasília, DF', + centerLat: -15.7939, + centerLon: -47.8828, + loadRadiusKm: 90, + stationInformationUrl: + 'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://brasilia.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, + { + id: 'curitiba-bike-itau', + city: 'Curitiba, PR', + centerLat: -25.4284, + centerLon: -49.2733, + loadRadiusKm: 90, + stationInformationUrl: + 'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_information.json', + stationStatusUrl: + 'https://curitiba.publicbikesystem.net/customer/gbfs/v2/en/station_status.json', + provider: 'Bike Itaú', + }, bcycleEntry({ id: 'boulder-bcycle', city: 'Boulder, CO', From ccdf8f0dc287129bf0e45158807cec26bf478bff Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Mon, 14 Sep 2026 13:45:20 +0200 Subject: [PATCH 02/76] feat(cctv): enrich identified cameras with datasheet FOV + specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a CCTV source pack (or a seed) names its hardware `model`, resolve it against a camera-model spec table: the coverage frustum then uses the manufacturer's horizontal FOV instead of the pose estimate, and the HUD meta line gains the model's resolution, night-vision, and PTZ specs. Cameras with no identified model are unchanged — unknown never implies anything, matching the layer's existing honesty about RAW PRIOR poses. 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 12,000+ camera models transcribed from manufacturer datasheets. This lands a small starter table plus the join + parsing helpers; source packs that publish their hardware can grow it. Records describe camera products (optics, resolution, night vision) only — no endpoints or access data. - src/layers/cctv/modelSpecs.js: spec table, case-insensitive/alias lookup, wide-end FOV parser, HUD summary formatter - src/layers/cctv/modelSpecs.test.mjs: lookup/alias/unknown, FOV parsing across the real fov string shapes, summary rendering - src/layers/cctv/catalog.js: apply enrichment in both catalog builders - src/layers/cctv/presentation.js: label a datasheet FOV, append the spec summary, and pass fovSource + spec through the public camera state --- src/layers/cctv/catalog.js | 27 ++++++ src/layers/cctv/modelSpecs.js | 129 ++++++++++++++++++++++++++++ src/layers/cctv/modelSpecs.test.mjs | 63 ++++++++++++++ src/layers/cctv/presentation.js | 11 ++- 4 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 src/layers/cctv/modelSpecs.js create mode 100644 src/layers/cctv/modelSpecs.test.mjs diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index 1a6ebe7c6..be6dc7e90 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -1,4 +1,25 @@ import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js'; +import { lookupModelSpec, horizontalFovDeg } from './modelSpecs.js'; + +/** + * Applies datasheet enrichment to a camera in place: when it names a known + * hardware `model`, the manufacturer's horizontal FOV replaces the pose + * estimate and the spec record is attached for the HUD. 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 {(v:number,min:number,max:number)=>number} clamp - Pose clamp helper. + */ +function applyModelEnrichment(camera, modelName, clamp) { + const spec = modelName ? lookupModelSpec(modelName) : null; + if (!spec) return; + const fov = horizontalFovDeg(spec); + if (fov) { + camera.fovDeg = clamp(fov, 20, 125); + camera.fovSource = 'datasheet'; + } + camera.spec = spec; +} export function createCatalog({ state: layerState, services, parts, source }) { const { CITY_POIS } = services.locations; @@ -45,6 +66,7 @@ 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); parts.model.ensureCameraPose(camera); catalog.push(camera); } @@ -205,6 +227,11 @@ export function createCatalog({ state: layerState, services, parts, source }) { : null, poseSource, }; + applyModelEnrichment( + camera, + source.model || seed?.model, + parts.model.clamp, + ); parts.model.ensureCameraPose(camera); catalog.push(camera); } diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js new file mode 100644 index 000000000..e9bb48856 --- /dev/null +++ b/src/layers/cctv/modelSpecs.js @@ -0,0 +1,129 @@ +/** + * @module cctvModelSpecs + * + * Hardware-model spec enrichment for the CCTV layer. + * + * When a catalog camera names its hardware model (source packs may set + * `model`; hand-authored seeds may too), the model resolves to a datasheet + * record: the camera's frustum then uses the manufacturer's horizontal FOV + * instead of an estimate, and the HUD meta line gains the model's real specs. + * Cameras without an identified model are untouched — unknown never implies + * anything. + * + * 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 12,000+ camera models transcribed from manufacturer datasheets (84% with + * FOV). This module ships a small starter table; city packs that publish + * their hardware models can grow it, or vendor the full extract. + * + * Scope guardrail: records describe camera PRODUCTS (optics, resolution, + * night vision) — never endpoints, stream paths, or access/config data. + */ + +/** Verbatim records from the dataset's model-enrichment extract (v2.19.0). */ +const RECORDS = [ + { + id: 'axis-q6135-le', + brand: 'Axis', + model: 'Q6135-LE', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/axis-q6135-le/', + aliases: ['01959-004'], + fov_deg: '58.3-2.4 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' }, + ptz: true, + autotracking: true, + varifocal: true, + night_vision: { type: 'ir', range_m: 250 }, + environment: ['outdoor'], + ndaa_compliant: true, + }, + { + id: 'hanwha-xnp-6400rw', + brand: 'Hanwha', + model: 'XNP-6400RW', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/hanwha-xnp-6400rw/', + aliases: ['Wisenet PTZ Plus 2MP 40x IR PTZ'], + fov_deg: '65.66-1.88 horizontal / 39.4-1.09 vertical', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' }, + ptz: true, + autotracking: true, + varifocal: true, + night_vision: { type: 'ir', range_m: 200 }, + environment: ['outdoor'], + ndaa_compliant: true, + }, + { + id: 'hikvision-ds-2cd2087g3-li2uy', + brand: 'Hikvision', + model: 'DS-2CD2087G3-LI2UY', + type: 'bullet', + url: 'https://www.cctv-database.com/camera/hikvision-ds-2cd2087g3-li2uy/', + fov_deg: '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)', + resolution: { mp: 8, w: 3840, h: 2160, label: '8MP (4K)' }, + night_vision: { type: 'color', range_m: 40 }, + environment: ['outdoor'], + ndaa_compliant: false, + }, + { + id: 'axis-f4105-lre', + brand: 'Axis', + model: 'F4105-LRE', + type: 'dome', + url: 'https://www.cctv-database.com/camera/axis-f4105-lre/', + fov_deg: '110 horizontal, 60 vertical', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, + night_vision: { type: 'ir', range_m: 10 }, + environment: ['outdoor'], + ndaa_compliant: true, + }, +]; + +const BY_MODEL = new Map(); +for (const record of RECORDS) { + BY_MODEL.set(record.model.toLowerCase(), record); + for (const alias of record.aliases || []) + BY_MODEL.set(alias.toLowerCase(), record); +} + +/** + * Looks up a spec record by model string (case-insensitive; aliases match too). + * @param {string} model - Hardware model as named by a source pack or seed. + * @returns {Object|null} The verbatim spec record, or null when unknown. + */ +export function lookupModelSpec(model) { + if (!model) return null; + return BY_MODEL.get(String(model).trim().toLowerCase()) || null; +} + +/** + * Datasheet horizontal FOV in degrees: the FIRST number in the `fov_deg` + * string, which for varifocal/PTZ ranges ('58.3-2.4 horizontal') is the wide + * end — the honest default for a coverage cone. + * @param {Object} spec - A record from this module. + * @returns {number|null} Wide-end horizontal FOV, or null when unstated. + */ +export function horizontalFovDeg(spec) { + const match = String(spec?.fov_deg || '').match(/\d+(?:\.\d+)?/); + const value = match ? Number(match[0]) : NaN; + return Number.isFinite(value) && value > 0 ? value : null; +} + +/** + * One-line spec summary for HUD meta text. + * @param {Object} spec - A record from this module. + * @returns {string} e.g. 'Axis Q6135-LE · 1080p · IR 250m · PTZ' ('' for null). + */ +export function specSummary(spec) { + if (!spec) return ''; + const parts = [`${spec.brand} ${spec.model}`]; + if (spec.resolution?.label) parts.push(spec.resolution.label); + if (spec.night_vision) { + parts.push( + `${spec.night_vision.type.toUpperCase()}${spec.night_vision.range_m ? ` ${spec.night_vision.range_m}m` : ''}`, + ); + } + if (spec.ptz) parts.push('PTZ'); + return parts.join(' · '); +} diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs new file mode 100644 index 000000000..4d6f80370 --- /dev/null +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -0,0 +1,63 @@ +// src/layers/cctv/modelSpecs.test.mjs +// Hardware-model spec enrichment: lookup semantics (case-insensitive, alias +// matching, unknown → null — never a guess), datasheet-FOV parsing across the +// real fov_deg string shapes, and the HUD spec-summary line. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + lookupModelSpec, + horizontalFovDeg, + specSummary, +} from './modelSpecs.js'; + +test('lookupModelSpec matches case-insensitively and trims', () => { + const spec = lookupModelSpec(' q6135-le '); + assert.ok(spec); + assert.equal(spec.brand, 'Axis'); + assert.equal(lookupModelSpec('Q6135-LE'), spec); +}); + +test('lookupModelSpec matches aliases', () => { + const byAlias = lookupModelSpec('01959-004'); + assert.ok(byAlias); + assert.equal(byAlias.model, 'Q6135-LE'); +}); + +test('lookupModelSpec returns null for unknown or empty input — never guesses', () => { + assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null); + assert.equal(lookupModelSpec(''), null); + assert.equal(lookupModelSpec(null), null); + assert.equal(lookupModelSpec(undefined), null); +}); + +test('horizontalFovDeg takes the wide end of a varifocal/PTZ range', () => { + // '58.3-2.4 horizontal' → 58.3 (wide), not 2.4 (tele). + assert.equal(horizontalFovDeg(lookupModelSpec('Q6135-LE')), 58.3); +}); + +test('horizontalFovDeg reads multi-variant and plain fov strings', () => { + // '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)' → first variant. + assert.equal(horizontalFovDeg(lookupModelSpec('DS-2CD2087G3-LI2UY')), 108.8); + // '110 horizontal, 60 vertical' → horizontal figure. + assert.equal(horizontalFovDeg(lookupModelSpec('F4105-LRE')), 110); +}); + +test('horizontalFovDeg returns null when no numeric FOV is stated', () => { + assert.equal(horizontalFovDeg({ fov_deg: '' }), null); + assert.equal(horizontalFovDeg({ fov_deg: 'wide dynamic range' }), null); + assert.equal(horizontalFovDeg({}), null); + assert.equal(horizontalFovDeg(null), null); +}); + +test('specSummary renders the HUD line and degrades by omission', () => { + assert.equal( + specSummary(lookupModelSpec('Q6135-LE')), + 'Axis Q6135-LE · 1080p · IR 250m · PTZ', + ); + // No night vision / no PTZ → those segments are simply absent. + assert.equal( + specSummary({ brand: 'Axis', model: 'X', resolution: { label: '4K' } }), + 'Axis X · 4K', + ); + assert.equal(specSummary(null), ''); +}); diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index f8f83fcd1..9fa4a7cef 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -1,6 +1,7 @@ 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 } from './modelSpecs.js'; export function createPresentation({ state: layerState, @@ -39,7 +40,8 @@ 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), - `FOV ${Math.round(active.camera.fovDeg)}°`, + // Identified hardware labels its FOV as datasheet-sourced (catalog.js). + `FOV ${Math.round(active.camera.fovDeg)}°${active.camera.fovSource === 'datasheet' ? ' (DATASHEET)' : ''}`, `COVERAGE ${area.toFixed(2)}km²`, overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW', `PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`, @@ -49,6 +51,10 @@ export function createPresentation({ ? `SRC ${String(health.sourceKind).toUpperCase()}` : `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`, `${viewBand.toUpperCase()} CONTEXT`, + // Identified hardware: append the model's datasheet specs + attribution. + active.camera.spec + ? `${specSummary(active.camera.spec).toUpperCase()} · SPECS: CCTV-DATABASE.COM` + : null, ] .filter(Boolean) .join(' · '); @@ -86,6 +92,9 @@ export function createPresentation({ headingEstimated: isHeadingEstimated(camera), pitchDeg: camera.pitchDeg, fovDeg: camera.fovDeg, + // Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js). + fovSource: camera.fovSource || 'estimated', + spec: camera.spec || null, rangeM: camera.rangeM, elevationM: camera.absoluteHeightM, mountHeightM: camera.mountHeightM, From 97118f478100ca4cebdba60b8eaf7f56f4e4de4e Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 17 Sep 2026 11:36:50 +0200 Subject: [PATCH 03/76] feat(cctv): vendor the model specs the integrated catalogs publish Grow the enrichment table from the 4-model starter to the hardware the currently integrated public catalogs actually publish, so identified cameras render datasheet FOV cones and spec cards instead of estimates: - King County (WA) road cameras: Cohu 3950 / 3960 (3965) / 3920 iDome (3925), Bosch AUTODOME IP 5000i. - Sarasota County (FL) + Sioux Falls (SD) open data: Bosch AUTODOME IP starlight 7000i / starlight 7000 HD / dynamic 7000 HD, the VG4 AUTODOME (500i), Bosch AUTODOME 800 (VG5-836), and Axis Q6155-E, Q6054-E, Q6000-E Mk II, and P3707-PE. All records are vendored verbatim from the CCTV Camera Database (CC0), each transcribed from a manufacturer datasheet. Analog Cohu positioners carry no fov_deg (their datasheets publish none), so they enrich the HUD specs without a datasheet cone; every other model carries a real FOV. Adds a test asserting the vendored table covers each integrated catalog's published hardware strings. --- src/layers/cctv/modelSpecs.js | 221 +++++++++++++++++++++++++++- src/layers/cctv/modelSpecs.test.mjs | 13 ++ 2 files changed, 232 insertions(+), 2 deletions(-) diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index e9bb48856..463e750ad 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -13,8 +13,11 @@ * 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 12,000+ camera models transcribed from manufacturer datasheets (84% with - * FOV). This module ships a small starter table; city packs that publish - * their hardware models can grow it, or vendor the full extract. + * FOV). 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 + * appear in Sarasota County FL and Sioux Falls SD open data; packs that + * publish other hardware models can grow it, or vendor the full extract. * * Scope guardrail: records describe camera PRODUCTS (optics, resolution, * night vision) — never endpoints, stream paths, or access/config data. @@ -78,6 +81,220 @@ const RECORDS = [ environment: ['outdoor'], ndaa_compliant: true, }, + { + id: 'axis-p3707-pe', + brand: 'Axis', + model: 'P3707-PE', + type: 'panoramic', + url: 'https://www.cctv-database.com/camera/axis-p3707-pe/', + aliases: ['AXIS P3707-PE', 'P3707-PE'], + fov_deg: '108-54 horizontal per sensor (4x 1080p)', + resolution: { + mp: 8, + w: 1920, + h: 1080, + label: '4x 2MP (8MP total; 4x 1080p)', + }, + lens_count: 4, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'axis-q6000-e-mk-ii', + brand: 'Axis', + model: 'Q6000-E Mk II', + type: 'panoramic', + url: 'https://www.cctv-database.com/camera/axis-q6000-e-mk-ii/', + aliases: [ + 'AXIS Q6000-E Mk II', + 'Q6000-E Mk II 50 Hz', + 'Q6000-E Mk II 60 Hz', + ], + fov_deg: 'up to 360 combined; 113-152 horizontal per sensor', + resolution: { + mp: 8, + w: 1920, + h: 1080, + label: '4x 2MP (8MP total; 4x 1080p)', + }, + lens_count: 4, + environment: ['outdoor'], + }, + { + id: 'axis-q6054-e', + brand: 'Axis', + model: 'Q6054-E', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/axis-q6054-e/', + aliases: ['AXIS Q6054-E', 'Q6054-E 50 Hz', 'Q6054-E 60 Hz'], + fov_deg: '62.9-2.2 horizontal', + resolution: { mp: 1, w: 1280, h: 720, label: '720p HD' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'axis-q6155-e', + brand: 'Axis', + model: 'Q6155-E', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/axis-q6155-e/', + aliases: ['AXIS Q6155-E', 'Q6155-E 50 Hz', 'Q6155-E 60 Hz'], + fov_deg: '66.7-2.36 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-autodome-500i', + brand: 'Bosch', + model: 'AutoDome 500i Series (VG4)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-autodome-500i/', + aliases: [ + 'VG4 AUTODOME', + 'VG4 AUTODOME H.264', + 'AutoDome 500i', + 'AutoDome 500i Series', + 'VG4-500i', + 'VG4-500', + ], + fov_deg: '57.8-1.7 horizontal', + resolution: { mp: 0.4, label: '540 TVL (NTSC/PAL)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-autodome-ip-dynamic-7000-hd', + brand: 'Bosch', + model: 'AUTODOME IP dynamic 7000 HD', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-dynamic-7000-hd/', + aliases: ['AUTODOME IP dynamic 7000 HD', 'AUTODOME 7000 dynamic HD'], + fov_deg: '65-2.3 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-autodome-ip-starlight-7000-hd', + brand: 'Bosch', + model: 'AUTODOME IP starlight 7000 HD', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-starlight-7000-hd/', + aliases: [ + 'AUTODOME IP starlight 7000 HD', + 'Starlight 7000HD', + 'VG5-7130-CPT4', + 'VG5-7130', + ], + fov_deg: '59-2.1 horizontal', + resolution: { mp: 1, w: 1280, h: 720, label: '720p HD (1.3MP sensor)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-ndp-5502-30', + brand: 'Bosch', + model: 'AUTODOME IP 5000i (NDP-5502-Z30)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-ndp-5502-30/', + aliases: [ + 'AUTODOME IP 5000i', + 'NDP-5502-Z30', + 'NDP-5502-Z30C', + 'NDP-5502-30', + ], + fov_deg: '60.9-2.4 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-ndp-7512-z30', + brand: 'Bosch', + model: 'AUTODOME IP starlight 7000i (NDP-7512-Z30)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-ndp-7512-z30/', + aliases: [ + 'AUTODOME IP starlight 7000i', + 'NDP-7512-Z30', + 'NDP-7512-Z30C', + 'NDP-7512-Z30K', + ], + fov_deg: '64.7-2.3 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-vg5-836-ecev', + brand: 'Bosch', + model: 'AUTODOME 800 Series HD PTZ (VG5-836-ECEV)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-vg5-836-ecev/', + aliases: ['VG5-836-ECEV', 'VG5-836', 'AUTODOME 800 Series'], + resolution: { mp: 2, label: '1080p' }, + ptz: true, + autotracking: true, + environment: ['outdoor'], + }, + { + id: 'cohu-3920-idome', + brand: 'Cohu', + model: '3920 iDome', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/cohu-3920-idome/', + aliases: [ + '3920', + '3920 Series', + '3923', + '3925', + '3925 Dome', + 'iDome 3925', + 'Cohu iDome 3920 Series', + ], + resolution: { mp: 0.4, label: 'NTSC / 470 TVL' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'cohu-3950-iview', + brand: 'Cohu', + model: '3950 iVIEW', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/cohu-3950-iview/', + aliases: ['3950', '3950 Series', 'Cohu 3950 Series iVIEW'], + resolution: { mp: 0.4, label: 'NTSC / 470 TVL' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'cohu-3960', + brand: 'Cohu', + model: '3960', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/cohu-3960/', + aliases: ['3960 Series', '3964', '3965', 'Cohu 3960 iView'], + resolution: { mp: 0.4, label: 'NTSC/PAL / 470 TVL' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, ]; const BY_MODEL = new Map(); diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index 4d6f80370..b31f2d714 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -23,6 +23,19 @@ test('lookupModelSpec matches aliases', () => { assert.equal(byAlias.model, 'Q6135-LE'); }); +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" + assert.equal(cohu?.brand, 'Cohu'); + const autodome = lookupModelSpec('AUTODOME IP 5000i'); + assert.ok(autodome && horizontalFovDeg(autodome) > 0); + const vg4 = lookupModelSpec('VG4 AUTODOME H.264'); // Sarasota's string + assert.ok(vg4); + const axis = lookupModelSpec('Q6155-E'); // Sioux Falls + assert.equal(horizontalFovDeg(axis), 66.7); +}); + test('lookupModelSpec returns null for unknown or empty input — never guesses', () => { assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null); assert.equal(lookupModelSpec(''), null); From 0533f78be433f28fe9085d856caf000630691670 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 17 Sep 2026 11:56:15 +0200 Subject: [PATCH 04/76] feat(cctv): resolve verbose registry model strings via token fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some catalogs publish a full product title rather than the bare model, e.g. "AXIS Q6155-E PTZ Dome Network Camera" — which the exact model/alias match misses. Add a fallback: after the exact match, scan for a record whose DISTINCTIVE token (a >=4-char model/alias word containing a digit or hyphen, so "dome"/"network"/"ptz"/"hd" never qualify) appears as a whole word in the query. The most specific (longest) matched token wins; if two records tie on the longest matched token the result is ambiguous and returns null, so the lookup never guesses. Exact matches are unchanged and still take precedence. --- scripts/package-boundaries.json | 3 ++ src/layers/cctv/modelSpecs.js | 69 +++++++++++++++++++++++++++-- src/layers/cctv/modelSpecs.test.mjs | 37 ++++++++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..a987623a4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1079,6 +1079,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", @@ -1496,6 +1497,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", @@ -2136,6 +2138,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 463e750ad..1252ce013 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -297,21 +297,82 @@ const RECORDS = [ }, ]; +/** Lowercase + collapse whitespace, the shared normal form for matching. */ +function normalize(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/\s+/g, ' '); +} + const BY_MODEL = new Map(); for (const record of RECORDS) { - BY_MODEL.set(record.model.toLowerCase(), record); + BY_MODEL.set(normalize(record.model), record); for (const alias of record.aliases || []) - BY_MODEL.set(alias.toLowerCase(), record); + BY_MODEL.set(normalize(alias), record); } /** - * Looks up a spec record by model string (case-insensitive; aliases match too). + * 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. + */ +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); + } + } + return [...out]; +} + +/** + * 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". + */ +const TOKEN_INDEX = RECORDS.flatMap((record) => + distinctiveTokens(record).map((token) => ({ + token, + record, + re: new RegExp( + `(?:^|[^a-z0-9])${token.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}(?:[^a-z0-9]|$)`, + ), + })), +); + +/** + * 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). * @param {string} model - Hardware model as named by a source pack or seed. * @returns {Object|null} The verbatim spec record, or null when unknown. */ export function lookupModelSpec(model) { if (!model) return null; - return BY_MODEL.get(String(model).trim().toLowerCase()) || 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; } /** diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index b31f2d714..c504e14ad 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -36,6 +36,43 @@ test('vendored table covers the integrated catalogs published hardware', () => { assert.equal(horizontalFovDeg(axis), 66.7); }); +test('verbose registry strings resolve via distinctive-token fallback', () => { + // Sioux Falls publishes the full product title, not the bare model. + assert.equal( + lookupModelSpec('AXIS Q6155-E PTZ Dome Network Camera')?.model, + 'Q6155-E', + ); + assert.equal(lookupModelSpec('AXIS Q6000-E Mk II')?.model, 'Q6000-E Mk II'); + assert.equal( + lookupModelSpec('AUTODOME IP starlight 7000i camera')?.id, + 'bosch-ndp-7512-z30', + ); + // Punctuation around the model token doesn't defeat the match. + assert.equal( + lookupModelSpec('camera (P3707-PE), outdoor')?.model, + 'P3707-PE', + ); +}); + +test('token fallback never matches on generic words or wrong models', () => { + // Only generic descriptors — no distinctive hardware token — stays null. + assert.equal(lookupModelSpec('PTZ Dome Camera'), null); + assert.equal(lookupModelSpec('HD Network Camera'), null); + assert.equal(lookupModelSpec('Outdoor Dome Camera'), null); + // A real-looking but unknown model resolves to nothing, not a near neighbour. + assert.equal(lookupModelSpec('AXIS FooBar-9 Network Camera'), null); + // A generation token alone must not collide with a similar one: "7000" + // belongs to two records (dynamic / starlight 7000 HD), so a bare + // "AUTODOME 7000" is ambiguous → null rather than an arbitrary pick. + assert.equal(lookupModelSpec('AUTODOME 7000'), null); +}); + +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'); + assert.equal(lookupModelSpec('AUTODOME IP 5000i')?.id, 'bosch-ndp-5502-30'); +}); + test('lookupModelSpec returns null for unknown or empty input — never guesses', () => { assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null); assert.equal(lookupModelSpec(''), null); From ffa1891b01b6d39def2d2854560d6e927d4c9cfb Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:09:27 +0000 Subject: [PATCH 05/76] fix(cctv): keep estimated FOV for PTZ/varifocal hardware; carry datasheet range as capability A PTZ/varifocal datasheet FOV is a capability range, not the lens's current zoom, so replacing the frustum's estimate with its wide end presented unknown optical state as known. Parse the full horizontal range (ignoring vertical, "combined" panoramic and lens-note figures), carry it as specFovMinDeg/specFovMaxDeg, and substitute the datasheet FOV only for fixed single-lens hardware. The HUD labels the range as capability beside the estimate. This also stops the Q6000-E Mk II's "360 combined" figure from being read as a 360-degree lens. --- src/layers/cctv/catalog.js | 25 ++++-- src/layers/cctv/modelSpecs.js | 64 +++++++++++--- src/layers/cctv/modelSpecs.test.mjs | 124 ++++++++++++++++++++++++---- src/layers/cctv/presentation.js | 7 +- 4 files changed, 186 insertions(+), 34 deletions(-) diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index be6dc7e90..499e715a0 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -1,11 +1,17 @@ import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js'; -import { lookupModelSpec, horizontalFovDeg } from './modelSpecs.js'; +import { + lookupModelSpec, + horizontalFovRangeDeg, + fixedHorizontalFovDeg, +} from './modelSpecs.js'; /** * Applies datasheet enrichment to a camera in place: when it names a known - * hardware `model`, the manufacturer's horizontal FOV replaces the pose - * estimate and the spec record is attached for the HUD. Unknown models — the - * common case — change nothing. + * 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. + * 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 {(v:number,min:number,max:number)=>number} clamp - Pose clamp helper. @@ -13,9 +19,14 @@ import { lookupModelSpec, horizontalFovDeg } from './modelSpecs.js'; function applyModelEnrichment(camera, modelName, clamp) { const spec = modelName ? lookupModelSpec(modelName) : null; if (!spec) return; - const fov = horizontalFovDeg(spec); - if (fov) { - camera.fovDeg = clamp(fov, 20, 125); + const range = horizontalFovRangeDeg(spec); + if (range) { + camera.specFovMinDeg = range.minDeg; + camera.specFovMaxDeg = range.maxDeg; + } + const fixedFov = fixedHorizontalFovDeg(spec); + if (fixedFov) { + camera.fovDeg = clamp(fixedFov, 20, 125); camera.fovSource = 'datasheet'; } camera.spec = spec; diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 1252ce013..a9d12e23d 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -5,8 +5,10 @@ * * When a catalog camera names its hardware model (source packs may set * `model`; hand-authored seeds may too), the model resolves to a datasheet - * record: the camera's frustum then uses the manufacturer's horizontal FOV - * instead of an estimate, and the HUD meta line gains the model's real specs. + * record and the HUD meta line gains the model's real specs. A fixed-lens + * model's horizontal FOV replaces the frustum's estimate; a PTZ/varifocal or + * multi-sensor range is carried as capability metadata beside the estimate, + * because the lens's current zoom is unknown. * Cameras without an identified model are untouched — unknown never implies * anything. * @@ -376,16 +378,58 @@ export function lookupModelSpec(model) { } /** - * Datasheet horizontal FOV in degrees: the FIRST number in the `fov_deg` - * string, which for varifocal/PTZ ranges ('58.3-2.4 horizontal') is the wide - * end — the honest default for a coverage cone. + * Datasheet horizontal FOV as the range the hardware can cover. Only + * segments stating a horizontal figure count — vertical and "combined" + * panoramic figures never do — and parenthesized lens/sensor notes are + * ignored. A zoom ('58.3-2.4 horizontal') and a lens-option list + * ('108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)') both yield a range. * @param {Object} spec - A record from this module. - * @returns {number|null} Wide-end horizontal FOV, or null when unstated. + * @returns {{minDeg:number, maxDeg:number}|null} Null when unstated. */ -export function horizontalFovDeg(spec) { - const match = String(spec?.fov_deg || '').match(/\d+(?:\.\d+)?/); - const value = match ? Number(match[0]) : NaN; - return Number.isFinite(value) && value > 0 ? value : null; +export function horizontalFovRangeDeg(spec) { + const values = []; + for (const segment of String(spec?.fov_deg || '').split(/[/;,]/)) { + if (!/horizontal/i.test(segment)) continue; + const figures = segment.replace(/\([^)]*\)/g, '').split(/horizontal/i)[0]; + for (const match of figures.matchAll(/\d+(?:\.\d+)?/g)) { + const value = Number(match[0]); + if (Number.isFinite(value) && value > 0) values.push(value); + } + } + if (!values.length) return null; + return { minDeg: Math.min(...values), maxDeg: Math.max(...values) }; +} + +/** + * The datasheet horizontal FOV as the camera's CURRENT optical state, which + * holds only for fixed single-lens hardware stating one figure. PTZ, + * varifocal and multi-sensor models publish a capability range whose in-use + * value is unknown — known hardware capability is not known pose state — so + * they return null and keep their estimated FOV. + * @param {Object} spec - A record from this module. + * @returns {number|null} Fixed horizontal FOV in degrees, or null. + */ +export function fixedHorizontalFovDeg(spec) { + if (!spec || spec.ptz || spec.varifocal || (spec.lens_count || 1) > 1) + return null; + const range = horizontalFovRangeDeg(spec); + return range && range.minDeg === range.maxDeg ? range.minDeg : null; +} + +/** + * HUD token for a datasheet FOV that describes capability rather than the + * camera's current state, e.g. 'SPEC FOV 2.4–58° (CAPABILITY)'. + * @param {Object} camera - Catalog camera (see applyModelEnrichment). + * @returns {string|null} Null when the datasheet FOV already IS the camera's + * FOV (fixed lens) or no datasheet range is known. + */ +export function specFovCapabilityToken(camera) { + if (!camera || camera.fovSource === 'datasheet') return null; + const { specFovMinDeg: min, specFovMaxDeg: max } = camera; + if (!Number.isFinite(min) || !Number.isFinite(max)) return null; + const fmt = (deg) => (deg < 10 ? deg.toFixed(1) : String(Math.round(deg))); + const span = min === max ? fmt(max) : `${fmt(min)}–${fmt(max)}`; + return `SPEC FOV ${span}° (CAPABILITY)`; } /** diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index c504e14ad..06ffe3a64 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -6,9 +6,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { lookupModelSpec, - horizontalFovDeg, + horizontalFovRangeDeg, + fixedHorizontalFovDeg, + specFovCapabilityToken, specSummary, } from './modelSpecs.js'; +import { createCatalog } from './catalog.js'; test('lookupModelSpec matches case-insensitively and trims', () => { const spec = lookupModelSpec(' q6135-le '); @@ -29,11 +32,11 @@ test('vendored table covers the integrated catalogs published hardware', () => { const cohu = lookupModelSpec('3950'); // King County publishes "3950" assert.equal(cohu?.brand, 'Cohu'); const autodome = lookupModelSpec('AUTODOME IP 5000i'); - assert.ok(autodome && horizontalFovDeg(autodome) > 0); + assert.ok(autodome && horizontalFovRangeDeg(autodome).maxDeg > 0); const vg4 = lookupModelSpec('VG4 AUTODOME H.264'); // Sarasota's string assert.ok(vg4); const axis = lookupModelSpec('Q6155-E'); // Sioux Falls - assert.equal(horizontalFovDeg(axis), 66.7); + assert.deepEqual(horizontalFovRangeDeg(axis), { minDeg: 2.36, maxDeg: 66.7 }); }); test('verbose registry strings resolve via distinctive-token fallback', () => { @@ -80,23 +83,112 @@ test('lookupModelSpec returns null for unknown or empty input — never guesses' assert.equal(lookupModelSpec(undefined), null); }); -test('horizontalFovDeg takes the wide end of a varifocal/PTZ range', () => { - // '58.3-2.4 horizontal' → 58.3 (wide), not 2.4 (tele). - assert.equal(horizontalFovDeg(lookupModelSpec('Q6135-LE')), 58.3); +test('horizontalFovRangeDeg reads the full range across real fov_deg shapes', () => { + const range = (model) => horizontalFovRangeDeg(lookupModelSpec(model)); + // PTZ zoom: both ends, not just the wide one. + assert.deepEqual(range('Q6135-LE'), { minDeg: 2.4, maxDeg: 58.3 }); + // Vertical figures never count. + assert.deepEqual(range('XNP-6400RW'), { minDeg: 1.88, maxDeg: 65.66 }); + // Lens options: parenthesized focal lengths are not FOV figures. + assert.deepEqual(range('DS-2CD2087G3-LI2UY'), { + minDeg: 93.3, + maxDeg: 108.8, + }); + assert.deepEqual(range('F4105-LRE'), { minDeg: 110, maxDeg: 110 }); + // The "360 combined" panoramic figure is not a horizontal lens FOV. + assert.deepEqual(range('Q6000-E Mk II'), { minDeg: 113, maxDeg: 152 }); + assert.deepEqual(range('P3707-PE'), { minDeg: 54, maxDeg: 108 }); }); -test('horizontalFovDeg reads multi-variant and plain fov strings', () => { - // '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)' → first variant. - assert.equal(horizontalFovDeg(lookupModelSpec('DS-2CD2087G3-LI2UY')), 108.8); - // '110 horizontal, 60 vertical' → horizontal figure. - assert.equal(horizontalFovDeg(lookupModelSpec('F4105-LRE')), 110); +test('horizontalFovRangeDeg returns null when no numeric FOV is stated', () => { + assert.equal(horizontalFovRangeDeg({ fov_deg: '' }), null); + assert.equal(horizontalFovRangeDeg({ fov_deg: 'wide dynamic range' }), null); + assert.equal(horizontalFovRangeDeg({ fov_deg: '60 vertical' }), null); + assert.equal(horizontalFovRangeDeg({}), null); + assert.equal(horizontalFovRangeDeg(null), null); }); -test('horizontalFovDeg returns null when no numeric FOV is stated', () => { - assert.equal(horizontalFovDeg({ fov_deg: '' }), null); - assert.equal(horizontalFovDeg({ fov_deg: 'wide dynamic range' }), null); - assert.equal(horizontalFovDeg({}), null); - assert.equal(horizontalFovDeg(null), null); +test('fixedHorizontalFovDeg is the current FOV only for fixed single-lens hardware', () => { + // Fixed dome, one horizontal figure: the datasheet IS the optical state. + assert.equal(fixedHorizontalFovDeg(lookupModelSpec('F4105-LRE')), 110); + // Known hardware capability != known pose state: PTZ, multi-sensor, and a + // model sold in several lens options all stay estimated. + for (const model of [ + 'Q6135-LE', + 'XNP-6400RW', + 'AUTODOME IP 5000i', + 'P3707-PE', + 'Q6000-E Mk II', + 'DS-2CD2087G3-LI2UY', + ]) { + assert.equal(fixedHorizontalFovDeg(lookupModelSpec(model)), null, model); + } + assert.equal(fixedHorizontalFovDeg(null), null); +}); + +test('specFovCapabilityToken labels a datasheet range as capability, never as current FOV', () => { + assert.equal( + specFovCapabilityToken({ specFovMinDeg: 2.4, specFovMaxDeg: 58.3 }), + 'SPEC FOV 2.4–58° (CAPABILITY)', + ); + assert.equal( + specFovCapabilityToken({ specFovMinDeg: 113, specFovMaxDeg: 113 }), + 'SPEC FOV 113° (CAPABILITY)', + ); + // Already the camera's FOV (fixed lens) or nothing known: no token. + assert.equal( + specFovCapabilityToken({ + fovSource: 'datasheet', + specFovMinDeg: 110, + specFovMaxDeg: 110, + }), + null, + ); + assert.equal(specFovCapabilityToken({}), null); + assert.equal(specFovCapabilityToken(null), null); +}); + +/** Browser catalog with the pose math stubbed: only enrichment is under test. */ +function browserCatalog() { + const model = { + safeNumber: (value, fallback = NaN) => + Number.isFinite(Number(value)) ? Number(value) : fallback, + normalizeHeading: (deg) => ((deg % 360) + 360) % 360, + clamp: (value, lo, hi) => Math.min(hi, Math.max(lo, value)), + headingFromId: () => 0, + normalizeFeedType: (type) => type, + ensureCameraPose: () => {}, + }; + return createCatalog({ + state: {}, + services: { locations: { CITY_POIS: {} } }, + parts: { model }, + source: {}, + }); +} + +test('catalog keeps the estimated FOV for PTZ hardware and substitutes 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: 'unknown', lat: 47.7, lon: -122, fovDeg: 44, model: 'NOPE-1' }, + ]); + + // PTZ: the frustum keeps its estimate; the datasheet range is metadata. + assert.equal(ptz.fovDeg, 44); + assert.equal(ptz.fovSource, undefined); + assert.equal(ptz.specFovMinDeg, 2.4); + assert.equal(ptz.specFovMaxDeg, 58.3); + assert.equal(ptz.spec.model, 'Q6135-LE'); + + // Fixed lens: the datasheet figure is the optical state. + assert.equal(fixed.fovDeg, 110); + assert.equal(fixed.fovSource, 'datasheet'); + + // Unknown hardware changes nothing. + assert.equal(unknown.fovDeg, 44); + assert.equal(unknown.spec, undefined); + assert.equal(unknown.specFovMaxDeg, undefined); }); test('specSummary renders the HUD line and degrades by omission', () => { diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index 9fa4a7cef..efa18e462 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -1,7 +1,7 @@ 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 } from './modelSpecs.js'; +import { specSummary, specFovCapabilityToken } from './modelSpecs.js'; export function createPresentation({ state: layerState, @@ -42,6 +42,9 @@ export function createPresentation({ headingHudToken(active.camera), // Identified hardware labels its FOV as datasheet-sourced (catalog.js). `FOV ${Math.round(active.camera.fovDeg)}°${active.camera.fovSource === 'datasheet' ? ' (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), `COVERAGE ${area.toFixed(2)}km²`, overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW', `PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`, @@ -94,6 +97,8 @@ export function createPresentation({ fovDeg: camera.fovDeg, // Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js). fovSource: camera.fovSource || 'estimated', + specFovMinDeg: camera.specFovMinDeg ?? null, + specFovMaxDeg: camera.specFovMaxDeg ?? null, spec: camera.spec || null, rangeM: camera.rangeM, elevationM: camera.absoluteHeightM, From cce159c93d6c7023e65589c1024bf83de45a1499 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:24:13 +0000 Subject: [PATCH 06/76] docs(cctv): document identified-hardware specs and datasheet FOV in CHANGELOG and CURRENT-STATE CONTRIBUTING requires runtime-behavior changes to update docs/CURRENT-STATE.md and CHANGELOG.md in the same PR. --- CHANGELOG.md | 9 +++++++++ docs/CURRENT-STATE.md | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..21a8cb743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +- 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). + - 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 @@ -235,6 +243,7 @@ Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). + - New Fire Perimeters layer (Events group): live NIFC WFIGS interagency wildfire incident perimeters as ground-clamped polygons with a containment-colored fire line, refreshed every 5 minutes from the public diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..5e1ccd447 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3390,6 +3390,13 @@ silently demoting every later lookup for the session. (`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). +- **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 + 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). - **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 From 7c7563d91b63be493312e3b89e088510274bbe97 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 21 Sep 2026 10:48:00 +0700 Subject: [PATCH 07/76] fix(gbfs): rate-limit /api/gbfs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy already restricted WHERE it would go — https, an allowlisted host, a station_information/station_status path, no redirects, a 12-second deadline and a 5 MB body cap — but nothing restricted HOW OFTEN, so the dev server would relay an unbounded stream of requests at a third-party bikeshare operator on any caller's behalf, one outbound fetch and one timeout timer each. Admit 120 requests/minute per client address, global backstop 3x, refused with 429 and `Retry-After` before the target is even parsed. The ceiling comes from the layer's own demand: `src/layers/bikeshare/registry.js` catalogues 15 systems, station information is cached per city after its first fetch, and station status refreshes once per city every STATUS_POLL_MS (60s) with in-flight requests deduplicated — about 15 requests a minute with everything in range, so 120 is eight times a maximal session. The method, host, path, redirect, deadline and size guards are unchanged. Refs #264 --- CHANGELOG.md | 9 ++ docs/CURRENT-STATE.md | 9 ++ scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 2 + server/providers/gbfs.js | 34 +++++- src/data/gbfsProxyRateLimit.test.mjs | 152 +++++++++++++++++++++++++++ 6 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 src/data/gbfsProxyRateLimit.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..f1c6ffb53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +- Bound how often the GBFS proxy will relay. `/api/gbfs` already restricted + where it could go — https, an allowlisted host, a station feed path, no + redirects, a 12-second deadline and a 5 MB body cap — but nothing restricted + how often, so the dev server would make unlimited requests at a third-party + bikeshare operator on any caller's behalf. The route now admits 120 + requests/minute per client address, refusing the rest with 429 and + `Retry-After` before any outbound fetch. The bikeshare layer needs about 15 a + minute with every catalogued system in range, so ordinary use is unchanged. + - 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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..f55945224 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -827,6 +827,15 @@ feed address is read from the path, and a query-string form is refused with a 40 streaming. The 12-second deadline includes reading the body, and rejected or stalled downloads are cancelled. Development and preview use the same handler. +The route also bounds request frequency: 120 requests/minute per client +address, with a global backstop of three times that. A refused request answers +429 with `Retry-After` and `Cache-Control: no-store` and performs no outbound +fetch. The bikeshare layer's own demand is about 15 requests a minute with +every catalogued system in range, so an ordinary session never meets the limit; +the method, host and path guards run unchanged. + +Updated: September 21, 2026 + ## Remaining local service modules Overpass query validation, geometry simplification, disk caching and upstream diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eee546dd5..e7b7a92d0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -839,5 +839,6 @@ "src/ui/imageryBoxTool.test.mjs", "src/ui/recentImagery.test.mjs", "src/layers/recentImagery/testDoubles.mjs", - "scripts/qa-recent-imagery.mjs" + "scripts/qa-recent-imagery.mjs", + "src/data/gbfsProxyRateLimit.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..1f1db490d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -138,6 +138,8 @@ "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", + "server/providers/common/rate-limit.js", + "src/sources/rateLimit.js", "src/data/gbfsSource.js", "src/sources/httpBody.js" ], diff --git a/server/providers/gbfs.js b/server/providers/gbfs.js index 9741bb4e6..001e94a7d 100644 --- a/server/providers/gbfs.js +++ b/server/providers/gbfs.js @@ -1,4 +1,5 @@ import { readResponseTextCapped } from './common/http.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; import { isAllowedGbfsHost, isAllowedGbfsPath, @@ -13,6 +14,19 @@ const GBFS_PROXY_TIMEOUT_MS = 12000; export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB +/** + * Requests/minute/IP admitted by the proxy. + * + * The bikeshare layer's own demand is small and known: `src/layers/bikeshare/ + * registry.js` catalogues 15 systems, station information is cached per city + * after its first fetch, and station status refreshes once per city every + * `STATUS_POLL_MS` (60 s) with in-flight requests deduplicated. Even with every + * catalogued system in camera range at once that is about 15 requests a minute, + * so 120 is eight times a maximal real session while still capping a caller + * using this proxy as an open relay against a third-party GBFS host. + */ +export const GBFS_RATE_PER_MIN = 120; + function gbfsRedirectHost(location, requestUrl) { if (!location) return ''; try { @@ -113,7 +127,12 @@ export async function fetchGbfsUpstream( * * @returns {import('vite').Plugin} */ -export function gbfsProxy() { +export function gbfsProxy({ ratePerMinute = GBFS_RATE_PER_MIN } = {}) { + const allow = makeRateLimiter({ + windowMs: 60_000, + max: ratePerMinute, + globalMax: ratePerMinute * 3, + }); const installMiddleware = (server) => { server.middlewares.use('/api/gbfs', async (req, res) => { try { @@ -126,6 +145,19 @@ export function gbfsProxy() { return; } + // Frequency, checked before the target is even parsed: the allowlist + // already says WHERE this proxy may go, nothing said HOW OFTEN. A + // refused request performs no outbound fetch and starts no timer. + if (!allow(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Retry-After': '10', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + const url = new URL(req.url || '/', 'http://localhost'); const encodedTarget = url.pathname.replace(/^\/+/, ''); if (!encodedTarget) { diff --git a/src/data/gbfsProxyRateLimit.test.mjs b/src/data/gbfsProxyRateLimit.test.mjs new file mode 100644 index 000000000..72ee38152 --- /dev/null +++ b/src/data/gbfsProxyRateLimit.test.mjs @@ -0,0 +1,152 @@ +// GBFS PROXY — the frequency the allowlist never bounded. +// +// `/api/gbfs` already says WHERE it may go: an https allowlisted host, a +// station_information/station_status path, a 12 s timeout, a 5 MB body cap and +// no redirects. Nothing said HOW OFTEN, so the dev server would relay an +// unbounded stream of requests at a third-party bikeshare operator on any +// caller's behalf — one outbound fetch and one timeout timer each. +// +// These cases drive the real mounted middleware. `fetch` is stubbed per test +// and restored afterwards, so a case that reaches the network fails loudly, +// and the stub's call count is what proves a refused request costs nothing. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { gbfsProxy, GBFS_RATE_PER_MIN } from '../../server/providers/gbfs.js'; + +const STATUS_URL = 'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_status.json'; +const STATION_BODY = '{"data":{"stations":[]}}'; + +/** Mount the proxy the way the dev server does; returns its route handler. */ +function mount(options) { + const routes = new Map(); + gbfsProxy(options).configureServer({ + middlewares: { + use(route, handler) { + routes.set(route, handler); + }, + }, + }); + return routes.get('/api/gbfs'); +} + +function request(handler, { target = STATUS_URL, remoteAddress, method } = {}) { + return new Promise((resolve, reject) => { + const req = { + method: method || 'GET', + // connect strips the mount prefix before the handler sees the URL. + url: '/' + encodeURIComponent(target), + headers: {}, + socket: remoteAddress ? { remoteAddress } : undefined, + }; + let status = 200; + let headers = {}; + const res = { + writeHead(code, sent) { + status = code; + headers = sent || {}; + return res; + }, + end(body = '') { + let parsed = null; + try { + parsed = body ? JSON.parse(String(body)) : null; + } catch { + parsed = String(body); + } + resolve({ status, headers, body: parsed }); + }, + }; + Promise.resolve(handler(req, res)).catch(reject); + }); +} + +/** Answer every allowlisted upstream, counting the calls that got through. */ +function stubUpstream(t) { + const asked = []; + const original = globalThis.fetch; + globalThis.fetch = async (url) => { + asked.push(String(url)); + return new Response(STATION_BODY, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + t.after(() => { + globalThis.fetch = original; + }); + return asked; +} + +test('a maximal real session is never throttled', async (t) => { + const asked = stubUpstream(t); + const handler = mount(); + + // The bikeshare layer catalogues 15 systems and refreshes each one's status + // once every 60 s, so a minute of the busiest possible session is ~15 + // requests. Run eight times that and expect every one of them served. + let served = 0; + for (let i = 0; i < 120; i += 1) { + const res = await request(handler); + if (res.status === 200) served += 1; + } + assert.equal(served, 120, 'the shipped ceiling leaves a real session alone'); + assert.equal(asked.length, 120); + assert.equal(GBFS_RATE_PER_MIN, 120, 'the ceiling this case relies on'); +}); + +test('an unbounded relay is refused, and refusal costs no outbound fetch', async (t) => { + const asked = stubUpstream(t); + const handler = mount({ ratePerMinute: 5 }); + + const responses = []; + for (let i = 0; i < 12; i += 1) responses.push(await request(handler)); + + const served = responses.filter((r) => r.status === 200); + const refused = responses.filter((r) => r.status === 429); + assert.equal(served.length, 5, 'exactly the admitted budget is served'); + assert.equal(refused.length, 7); + assert.equal(refused[0].body.error, 'Rate limit exceeded'); + assert.equal(refused[0].headers['Retry-After'], '10'); + assert.equal(refused[0].headers['Cache-Control'], 'no-store'); + assert.equal( + asked.length, + 5, + 'a refused request never reaches the GBFS operator', + ); +}); + +test('a different client keeps its own budget', async (t) => { + stubUpstream(t); + const handler = mount({ ratePerMinute: 3 }); + + for (let i = 0; i < 6; i += 1) + await request(handler, { remoteAddress: '10.0.0.1' }); + const noisy = await request(handler, { remoteAddress: '10.0.0.1' }); + assert.equal(noisy.status, 429, 'the noisy client spent its own budget'); + + const quiet = await request(handler, { remoteAddress: '10.0.0.2' }); + assert.equal(quiet.status, 200, 'a quiet client is not collateral'); +}); + +test('the limiter does not displace the existing guards', async (t) => { + const asked = stubUpstream(t); + const handler = mount({ ratePerMinute: 10 }); + + const wrongMethod = await request(handler, { method: 'POST' }); + assert.equal(wrongMethod.status, 405); + + const offAllowlist = await request(handler, { + target: 'https://example.com/gbfs/station_status.json', + }); + assert.equal(offAllowlist.status, 403); + assert.equal(offAllowlist.body.error, 'GBFS host not allowed'); + + const wrongPath = await request(handler, { + target: 'https://gbfs.lyft.com/gbfs/2.3/bay/en/system_regions.json', + }); + assert.equal(wrongPath.status, 400); + + assert.equal(asked.length, 0, 'none of those reached upstream'); +}); From bde1e430efdee543f5a9ff88e341843d8569c2a9 Mon Sep 17 00:00:00 2001 From: Meir Michanie Date: Thu, 24 Sep 2026 20:03:23 +0200 Subject: [PATCH 08/76] feat(i18n): locale core + English catalogs + parity gates Add the i18n foundation: src/i18n/locale.js (locale resolution with a configurable GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE pair, guarded preference storage, one-shot ?lang= override, / metadata) and src/i18n/index.js (catalog registry, t(), Intl formatters, applyDocumentTranslations, persistLocaleAndReload). English is the only shipped locale in this PR: the four en catalogs (shell/cockpit/layers/setup) are the reference and the always-shipped fallback, the built-in pair degenerates to en-only, and English output is byte-identical to the strings these replace. Parity gates land with the core (catalog key/placeholder/plural-shape parity, markup coverage of every data-i18n* attribute, repair-pass anchors pinning en byte-identity); additional locales ship as stacked follow-up PRs through the recipe in docs/TRANSLATORS.md. Rebased onto current main: the client defines live in build/vite.js / server/standalone/vite.config.js as before. --- .env.example | 14 + build/vite.js | 10 + server/standalone/vite.config.js | 2 + src/i18n/catalog.test.mjs | 198 +++++++++++ src/i18n/i18n.test.mjs | 457 +++++++++++++++++++++++++ src/i18n/index.js | 348 +++++++++++++++++++ src/i18n/locale.js | 314 +++++++++++++++++ src/i18n/locales/en/cockpit.js | 560 +++++++++++++++++++++++++++++++ src/i18n/locales/en/layers.js | 501 +++++++++++++++++++++++++++ src/i18n/locales/en/setup.js | 211 ++++++++++++ src/i18n/locales/en/shell.js | 82 +++++ src/tooling/viteBuild.test.mjs | 5 + 12 files changed, 2702 insertions(+) create mode 100644 src/i18n/catalog.test.mjs create mode 100644 src/i18n/i18n.test.mjs create mode 100644 src/i18n/index.js create mode 100644 src/i18n/locale.js create mode 100644 src/i18n/locales/en/cockpit.js create mode 100644 src/i18n/locales/en/layers.js create mode 100644 src/i18n/locales/en/setup.js create mode 100644 src/i18n/locales/en/shell.js diff --git a/.env.example b/.env.example index 67a1e74a7..a0f089772 100644 --- a/.env.example +++ b/.env.example @@ -91,6 +91,20 @@ LL2_API_TOKEN= # Optional: OpenSky credentials JSON file path (alternative to above) # OPENSKY_CREDENTIALS_FILE=/path/to/credentials.json +# Optional: interface language pair (i18n). GEV_DEFAULT_LOCALE is the default +# UI locale; GEV_SECONDARY_LOCALE is the second locale offered in the dock +# language switch. English is ALWAYS shipped as the fallback catalog and is +# resolvable no matter the pair. Only 'en' ships in this PR: both values are +# validated against the shipped catalogs, so a secondary without a shipped +# catalog degenerates the switch to a single English button (and an invalid, +# unshipped, or degenerate pair falls back to that en-only shape with a +# dev-server-only console.warn). Later locale PRs re-open the pair — see +# docs/TRANSLATORS.md. CLIENT-EXPOSED (non-secret): both values reach the +# browser bundle via import.meta.env defines — do not put anything sensitive +# here. +# GEV_DEFAULT_LOCALE=en +# GEV_SECONDARY_LOCALE=es + # Optional: dev server config PORT=4173 # Network binding. Default is localhost — the server is reachable ONLY from diff --git a/build/vite.js b/build/vite.js index cdff4a27c..bf2ea9f98 100644 --- a/build/vite.js +++ b/build/vite.js @@ -7,6 +7,8 @@ export function createBrowserViteConfig({ publicDir, googleApiKey, cesiumToken, + defaultLocale = '', + secondaryLocale = '', host = 'localhost', port = 4173, command, @@ -47,6 +49,14 @@ export function createBrowserViteConfig({ define: { 'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey), 'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken), + // Locale pair (i18n): the client's default + secondary UI locale. These + // are NOT secrets and are read by src/i18n/locale.js, which validates + // them against the shipped catalogs and falls back to the built-in + // en-only pair (while English is the only shipped catalog) for + // empty/invalid/unshipped values. Explicit define entries are required + // because Vite only auto-exposes VITE_-prefixed vars on import.meta.env. + 'import.meta.env.GEV_DEFAULT_LOCALE': JSON.stringify(defaultLocale), + 'import.meta.env.GEV_SECONDARY_LOCALE': JSON.stringify(secondaryLocale), }, build: { chunkSizeWarningLimit: 1500 }, }; diff --git a/server/standalone/vite.config.js b/server/standalone/vite.config.js index ffa5a1e27..ae4a62043 100644 --- a/server/standalone/vite.config.js +++ b/server/standalone/vite.config.js @@ -16,6 +16,8 @@ export default defineConfig(({ command, mode }) => { plugins: [...localProviderPlugins(), apiNotFoundPlugin()], googleApiKey: process.env.GOOGLE_MAPS_API_KEY, cesiumToken: process.env.CESIUM_ION_TOKEN, + defaultLocale: process.env.GEV_DEFAULT_LOCALE ?? '', + secondaryLocale: process.env.GEV_SECONDARY_LOCALE ?? '', host: process.env.HOST, port: process.env.PORT, command, diff --git a/src/i18n/catalog.test.mjs b/src/i18n/catalog.test.mjs new file mode 100644 index 000000000..9b8997f8d --- /dev/null +++ b/src/i18n/catalog.test.mjs @@ -0,0 +1,198 @@ +// Catalog parity gates: every shipped non-English locale (the es follow-up, +// then one PR per further locale) is checked against en — it may never LEAD en +// (no extra keys), every shared key must keep identical placeholder names, +// and plural entries must carry every en variant plus only categories +// Intl.PluralRules reports valid for that locale (see assertVariantSuperset +// below); the strict gate demands exact key-set equality so a forgotten +// translation cannot ship silently behind the English fallback. While English +// is the only shipped locale these loops are vacuous — the gate machinery +// stays so each locale PR meets it from day one. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getCatalog, mergeNamespace } from './index.js'; +import { CATALOG_LOCALES } from './locale.js'; + +/* + * PARITY FLIP — how the strict gate is previewed or relaxed. + * ───────────────────────────────────────────────────────────────────────────── + * REQUIRE_FULL_PARITY is on by default: every shipped locale must hold the + * exact en key set. While a NEW catalog is still an untranslated seed that + * lags en, run CI with GEV_I18N_REQUIRE_FULL_LOCALE_PARITY=0 to drop back to + * the subset rules (a locale may be a subset of en, never a superset). The + * pre-generalization name GEV_I18N_REQUIRE_FULL_ES_PARITY is still honored as + * an alias. Record every flip in docs/TRANSLATORS.md. + */ +const PARITY_OPT_OUT = process.env.GEV_I18N_REQUIRE_FULL_LOCALE_PARITY + ?? process.env.GEV_I18N_REQUIRE_FULL_ES_PARITY; +const REQUIRE_FULL_PARITY = PARITY_OPT_OUT !== '0'; + +const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g; + +/** Placeholder names used by one catalog entry (string or plural variants). */ +function placeholdersOf(entry) { + const patterns = typeof entry === 'string' ? [entry] : Object.values(entry || {}); + const names = new Set(); + for (const pattern of patterns) { + if (typeof pattern !== 'string') continue; + for (const match of pattern.matchAll(PLACEHOLDER_PATTERN)) names.add(match[1]); + } + return names; +} + +/* + * PLURAL-SHAPE PARITY, GENERALIZED FOR MULTI-CATEGORY LOCALES + * ───────────────────────────────────────────────────────────────────────────── + * en plural entries carry { one, other } — the only cardinal categories + * Intl.PluralRules reports for English. Slavic locales such as ru/uk select + * one/few/many/other, so exact-shape equality with en would forbid their + * grammars. The rule is now, per locale and shared key: + * (a) the entry must carry EVERY variant en has (they are mandatory — the + * runtime reads entry. ?? entry.other, so dropping an en + * variant strands the fallback), and + * (b) every variant added beyond en's must be a cardinal category + * Intl.PluralRules reports VALID for that locale + * (resolvedOptions().validCategories, spelled pluralCategories on + * older runtimes; e.g. 'few'/'many' are valid for ru/uk but not en). + * en itself can never grow a variant (English's valid set is exactly + * one/other), and a typo'd variant like 'xother' fails loudly for any + * locale. The runtime degrades gracefully the other way: an entry lacking a + * category its locale would select renders entry.other (selectPluralPattern + * in src/i18n/index.js; pinned in i18n.test.mjs). + */ + +/** Cardinal plural categories Intl may select for a locale. */ +function validPluralCategories(locale) { + const { validCategories, pluralCategories } = new Intl.PluralRules(locale).resolvedOptions(); + // The property was renamed pluralCategories → validCategories in newer + // ECMAScript drafts; read whichever the running ICU exposes. + const categories = validCategories ?? pluralCategories; + assert.ok(Array.isArray(categories), `Intl.PluralRules(${locale}) exposes no category set`); + return new Set(categories); +} + +/** Assert the plural-shape rule for one locale entry against its en twin. */ +function assertVariantSuperset(localeEntry, enEntry, locale, key) { + assert.equal( + typeof localeEntry, + typeof enEntry, + `variant-shape drift on ${locale}:${key}: string ↔ plural-object mismatch`, + ); + if (typeof enEntry === 'string') return; + const enVariants = Object.keys(enEntry); + const variants = Object.keys(localeEntry); + const missing = enVariants.filter((variant) => !variants.includes(variant)); + assert.deepEqual( + missing, + [], + `${locale}:${key} must keep every en variant (${enVariants.join(',')})`, + ); + const valid = validPluralCategories(locale); + const invalid = variants.filter((variant) => !enVariants.includes(variant) && !valid.has(variant)); + assert.deepEqual( + invalid, + [], + `${locale}:${key} adds categories Intl.PluralRules(${locale}) never selects: ${invalid.join(',')}`, + ); +} + +const enKeys = Object.keys(getCatalog('en')).sort(); +// Every shipped non-en locale with its sorted key list. +const localeKeys = new Map( + CATALOG_LOCALES + .filter((locale) => locale !== 'en') + .map((locale) => [locale, Object.keys(getCatalog(locale)).sort()]), +); + +test('every shipped locale ships a merged catalog', () => { + // English only in the foundation state; a locale PR appends its code here + // alongside its catalog files (docs/TRANSLATORS.md). + assert.deepEqual([...CATALOG_LOCALES].sort(), ['en']); + for (const locale of CATALOG_LOCALES) { + assert.ok(getCatalog(locale), `merged catalog for ${locale}`); + } +}); + +test('no locale carries keys that en does not have', () => { + for (const [locale, keys] of localeKeys) { + const extras = keys.filter((key) => !enKeys.includes(key)); + assert.deepEqual( + extras, + [], + `extra ${locale} keys leak untranslated-only surfaces: ${extras.join(', ')}`, + ); + } +}); + +test('placeholder names match en; plural variants form an en-superset of Intl-valid categories', () => { + const enCatalog = getCatalog('en'); + for (const [locale, keys] of localeKeys) { + const catalog = getCatalog(locale); + for (const key of keys) { + assert.deepEqual( + [...placeholdersOf(catalog[key])].sort(), + [...placeholdersOf(enCatalog[key])].sort(), + `placeholder drift on ${locale}:${key}: a renamed {name} would break interpolation at runtime`, + ); + assertVariantSuperset(catalog[key], enCatalog[key], locale, key); + } + } +}); + +test('plural-shape rule: ru accepts its full Intl category set and rejects categories it never selects', () => { + const enEntry = { one: 'Cleared {count} layer', other: 'Cleared {count} layers' }; + // ru selects one/few/many/other, so the full four-variant translation of + // an en { one, other } twin is a legal superset (adds only valid ru + // categories) — exactly what stage-B translators must produce… + assert.doesNotThrow(() => assertVariantSuperset( + { one: '…', few: '…', many: '…', other: '…' }, + enEntry, + 'ru', + 'layers.clear.toast.cleared', + )); + // …while a variant Intl never selects for ru (a typo like 'xother') fails. + assert.throws( + () => assertVariantSuperset({ one: '…', other: '…', xother: '…' }, enEntry, 'ru', 'demo.key'), + /xother/, + ); + // Dropping a mandatory en variant fails even when all additions are valid. + assert.throws( + () => assertVariantSuperset({ few: '…', many: '…', other: '…' }, enEntry, 'ru', 'demo.key'), + /every en variant/, + ); + // String ↔ plural-object drift still fails exactly as before. + assert.throws( + () => assertVariantSuperset('plain string', enEntry, 'ru', 'demo.key'), + /string ↔ plural-object/, + ); + // The gate stays honest about what the runtime can select: validCategories + // is read from Intl, not hardcoded per locale. + assert.deepEqual([...validPluralCategories('en')].sort(), ['one', 'other']); + assert.deepEqual([...validPluralCategories('ru')].sort(), ['few', 'many', 'one', 'other']); +}); + +test('exact key parity for every shipped locale once translation is complete (parity flip)', () => { + if (!REQUIRE_FULL_PARITY) { + // Subset mode: every locale key resolves; en-only keys fall back silently. + for (const [locale, keys] of localeKeys) { + for (const key of keys) assert.ok(enKeys.includes(key), `${locale}:${key}`); + } + return; + } + for (const [locale, keys] of localeKeys) { + assert.deepEqual(keys, enKeys, `strict gate is on: ${locale} must be key-complete`); + } +}); + +test('mergeNamespace prefixes relative keys and rejects malformed namespaces', () => { + const merged = mergeNamespace({ NAMESPACE: 'demo', default: { 'a.b': 'x', plain: 'y' } }); + assert.deepEqual({ ...merged }, { 'demo.a.b': 'x', 'demo.plain': 'y' }); + assert.throws(() => mergeNamespace({}), /must export a lowercase NAMESPACE/); + assert.throws(() => mergeNamespace({ NAMESPACE: 'Nope' }), /must export a lowercase NAMESPACE/); + assert.throws(() => mergeNamespace({ NAMESPACE: '' }), /must export a lowercase NAMESPACE/); + assert.deepEqual({ ...mergeNamespace({ NAMESPACE: 'empty' }) }, {}); +}); + +test('registered namespaces produce the documented dot prefixes', () => { + const prefixes = new Set(enKeys.map((key) => key.split('.')[0])); + assert.deepEqual([...prefixes].sort(), ['cockpit', 'layers', 'setup', 'shell']); +}); diff --git a/src/i18n/i18n.test.mjs b/src/i18n/i18n.test.mjs new file mode 100644 index 000000000..0b9ca8a23 --- /dev/null +++ b/src/i18n/i18n.test.mjs @@ -0,0 +1,457 @@ +// i18n core behavior: locale resolution precedence (incl. ?lang=), guarded +// storage, English fallback, interpolation, plural selection, document +// application on all four attributes, and hash-exact reload handling. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + CATALOG_LOCALES, + DEFAULT_LOCALE, + LOCALE_METADATA, + LOCALE_STORAGE_KEY, + applyDocumentLanguage, + availableLocales, + normalizeLocale, + resolveLocale, + resolveLocalePair, + writeStoredLocale, +} from './locale.js'; +import { + applyDocumentTranslations, + formatDate, + formatNumber, + formatRelativeTime, + getCatalog, + getLocale, + hasMessage, + persistLocaleAndReload, + resolveMessage, + selectPluralPattern, + setLocale, + t, +} from './index.js'; + +// Tests mutate module locale state; each dependent test re-pins it. +function pinLocale(locale) { + assert.equal(setLocale(locale), locale); +} + +test('normalizeLocale folds regional variants and rejects unsupported tags', () => { + assert.equal(normalizeLocale('es'), 'es'); + assert.equal(normalizeLocale('ES'), 'es'); + assert.equal(normalizeLocale('es-MX'), 'es'); + assert.equal(normalizeLocale('es_419'), 'es'); + assert.equal(normalizeLocale('en'), 'en'); + assert.equal(normalizeLocale('en-GB'), 'en'); + assert.equal(normalizeLocale('fr'), 'fr'); + assert.equal(normalizeLocale('FR'), 'fr'); + assert.equal(normalizeLocale('fr-CA'), 'fr'); + assert.equal(normalizeLocale('fr_FR'), 'fr'); + assert.equal(normalizeLocale('ru'), 'ru'); + assert.equal(normalizeLocale('RU'), 'ru'); + assert.equal(normalizeLocale('ru-RU'), 'ru'); + assert.equal(normalizeLocale('ru_KZ'), 'ru'); + assert.equal(normalizeLocale('uk'), 'uk'); + assert.equal(normalizeLocale('UK'), 'uk', 'uppercase country spelling still folds to the language tag'); + assert.equal(normalizeLocale('uk-UA'), 'uk'); + assert.equal(normalizeLocale('uk_UA'), 'uk'); + assert.equal(normalizeLocale('de'), null, 'no shipped de catalog'); + assert.equal(normalizeLocale('english'), null); + assert.equal(normalizeLocale(''), null); + assert.equal(normalizeLocale(null), null); + assert.equal(normalizeLocale(42), null); +}); + +test('resolution precedence: ?lang= is one-shot, pair-scoped, and never persisted', () => { + // English is the only shipped catalog in the foundation state, so the + // offered pair is en-only: an override naming any other locale — a + // planned-but-unshipped code ('es') or garbage ('de') — is ignored while + // the rest of the chain keeps looking. The override-beats-everything pin + // comes back with the first secondary-locale PR (docs/TRANSLATORS.md). + const storage = { getItem: () => 'en', setItem() { throw new Error('must not be written'); } }; + assert.equal(resolveLocale({ + location: { search: '?lang=es' }, + storage, + languages: ['en-US'], + }), 'en'); + // A garbage override is ignored — the chain keeps looking. + assert.equal(resolveLocale({ + location: { search: '?lang=de' }, + storage, + languages: ['en-US'], + }), 'en'); + // A locale code that normalizes but has no shipped catalog is equally + // ignored: fr cannot be offered before its locale PR lands. + assert.equal(resolveLocale({ + location: { search: '?lang=fr' }, + storage, + languages: ['en-US'], + }), 'en'); +}); + +test('resolution precedence: a stored preference is re-checked against the offered pair', () => { + // 'es' may have been stored under a pair that once offered it; while + // English is the only shipped catalog the stored value is out-of-pair and + // reads as absent, so the navigator list decides — the stored preference + // degrades gracefully instead of rendering English under an es . + assert.equal(resolveLocale({ + location: { search: '' }, + storage: { getItem: () => 'es' }, + languages: ['en-US', 'en'], + }), 'en'); +}); + +test('resolution precedence: navigator languages resolve before the English default', () => { + // Regional variants normalize to the primary tag and only then face the + // pair check: 'en-GB' resolves through the offered pair, unshipped codes + // ('fr-CA', 'es-MX') defer to the next step. + assert.equal(resolveLocale({ + location: { search: '' }, + storage: { getItem: () => 'xx' }, + languages: ['en-GB'], + }), 'en'); + assert.equal(resolveLocale({ + location: { search: '' }, + storage: { getItem: () => 'xx' }, + languages: ['fr-CA', 'es-MX'], + }), 'en', 'no shipped fr/es catalog: neither navigator entry resolves'); + assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: ['fr-CA'] }), 'en'); + assert.equal(resolveLocale({ location: null, storage: null, languages: null }), DEFAULT_LOCALE); +}); + +test('guarded storage: a throwing store (private mode / quota) never escapes', () => { + const hostile = { + getItem() { throw new Error('SecurityError'); }, + setItem() { throw new Error('SecurityError'); }, + }; + assert.equal(resolveLocale({ location: { search: '' }, storage: hostile, languages: ['es'] }), 'en', + 'an unshipped navigator locale defers to the en-only default'); + assert.equal(writeStoredLocale('es', hostile), false); + assert.equal(writeStoredLocale('es', { setItem() { throw new Error('quota'); } }), false); + assert.equal(writeStoredLocale('es', {}), false, 'no setItem function means no write'); + assert.equal(writeStoredLocale('es-AR', { setItem() {} }), true, 'stored value is normalized'); +}); + +test('applyDocumentLanguage reflects lang and dir from locale metadata', () => { + const makeDoc = () => ({ documentElement: { lang: '', dir: '' } }); + const enDoc = makeDoc(); + assert.equal(applyDocumentLanguage(enDoc, 'en'), true); + assert.deepEqual({ lang: enDoc.documentElement.lang, dir: enDoc.documentElement.dir }, + { lang: 'en', dir: LOCALE_METADATA.en.dir }); + assert.equal(LOCALE_METADATA.en.dir, 'ltr', 'English is LTR by metadata'); + const esDoc = makeDoc(); + assert.equal(applyDocumentLanguage(esDoc, 'es-MX'), true); + assert.deepEqual({ lang: esDoc.documentElement.lang, dir: esDoc.documentElement.dir }, + { lang: 'es', dir: LOCALE_METADATA.es.dir }); + assert.equal(LOCALE_METADATA.es.dir, 'ltr', 'Spanish is LTR by metadata'); + const frDoc = makeDoc(); + assert.equal(applyDocumentLanguage(frDoc, 'fr-CA'), true); + assert.deepEqual({ lang: frDoc.documentElement.lang, dir: frDoc.documentElement.dir }, + { lang: 'fr', dir: LOCALE_METADATA.fr.dir }); + assert.equal(LOCALE_METADATA.fr.dir, 'ltr', 'French is LTR by metadata'); + const ruDoc = makeDoc(); + assert.equal(applyDocumentLanguage(ruDoc, 'ru-RU'), true); + assert.deepEqual({ lang: ruDoc.documentElement.lang, dir: ruDoc.documentElement.dir }, + { lang: 'ru', dir: LOCALE_METADATA.ru.dir }); + assert.equal(LOCALE_METADATA.ru.dir, 'ltr', 'Russian is LTR by metadata'); + const ukDoc = makeDoc(); + assert.equal(applyDocumentLanguage(ukDoc, 'uk_UA'), true); + assert.deepEqual({ lang: ukDoc.documentElement.lang, dir: ukDoc.documentElement.dir }, + { lang: 'uk', dir: LOCALE_METADATA.uk.dir }); + assert.equal(LOCALE_METADATA.uk.dir, 'ltr', 'Ukrainian is LTR by metadata'); + assert.equal(applyDocumentLanguage(null, 'es'), false); + assert.equal(applyDocumentLanguage({ documentElement: null }, 'es'), false); + // An unsupported locale still yields valid metadata, never an empty dir. + const fallbackDoc = makeDoc(); + applyDocumentLanguage(fallbackDoc, 'de'); + assert.equal(fallbackDoc.documentElement.lang, DEFAULT_LOCALE); + assert.equal(fallbackDoc.documentElement.dir, 'ltr'); +}); + +test('t() resolves seed keys, interpolates named placeholders, and keeps unknown ones literal', () => { + pinLocale('en'); + assert.equal(t('shell.title.subtitle'), 'NO PLACE LEFT BEHIND'); + assert.equal( + t('shell.loading.status.tilesUnavailable', { detail: '403 from ion' }), + 'Google 3D Tiles unavailable (403 from ion). Loading the keyless globe...', + ); + assert.equal( + t('shell.loading.status.tilesUnavailable', {}), + 'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...', + 'an unprovided name stays visible instead of silently vanishing', + ); +}); + +test('t() selects one/other plural variants through Intl.PluralRules', () => { + pinLocale('en'); + assert.equal(t('layers.clear.toast.cleared', { count: 1 }), 'Cleared 1 data layer'); + assert.equal(t('layers.clear.toast.cleared', { count: 3 }), 'Cleared 3 data layers'); + assert.equal(t('layers.clear.toast.notCleared', { count: 1 }), + '1 data layer could not be cleared'); + // Non-en plural-category selection is pinned per locale in that locale's + // PR (es: one/other; ru/uk: one/few/many/other) — with real catalog values, + // not synthetic ones. The selection CONTRACT itself stays pinned below + // through the synthetic missing-category fallback under 'ru'. +}); + +test('a missing key returns the key itself and never warns outside dev builds', () => { + pinLocale('en'); + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + assert.equal(t('shell.definitely.not.here'), 'shell.definitely.not.here'); + assert.equal(hasMessage('shell.definitely.not.here'), false); + assert.equal(hasMessage('shell.title.subtitle'), true); + } finally { + console.warn = originalWarn; + } + // import.meta.env is absent under node:test, so the DEV gate keeps the + // console quiet here; the warn path itself only runs on the dev server. + assert.deepEqual(warnings, []); +}); + +test('a key missing in es resolves through the English fallback (pure lookup)', () => { + // The shipped es seed mirrors en key-for-key, so the fallback path is + // exercised through the pure resolver with synthetic catalogs — exactly the + // shape stage-3 leaves behind while es translation lags. + const catalogs = { + es: Object.freeze({}), + en: Object.freeze({ 'a.b': 'EN ONLY' }), + }; + assert.equal(resolveMessage(catalogs, 'es', 'a.b'), 'EN ONLY'); + assert.equal(resolveMessage(catalogs, 'fr', 'a.b'), 'EN ONLY', 'unsupported locale reads en'); + assert.equal(resolveMessage(catalogs, 'es', 'nope'), undefined); + assert.equal(resolveMessage(null, 'es', 'a.b'), undefined, 'no catalogs means no message'); +}); + +test('Intl formatters follow the active locale', () => { + pinLocale('en'); + // Five significant digits: Spanish (CLDR minimumGroupingDigits=2) only + // groups from five digits up, so a 4-digit value would hide the difference. + assert.equal(formatNumber(12345.6), '12,345.6'); + assert.equal(formatDate(new Date(Date.UTC(2026, 0, 15)), { year: 'numeric', month: 'long', day: 'numeric' }), + 'January 15, 2026'); + assert.equal(formatRelativeTime(-5, 'minute'), '5 minutes ago'); + pinLocale('es'); + assert.equal(formatNumber(12345.6), '12.345,6'); + assert.equal(formatDate(new Date(Date.UTC(2026, 0, 15)), { year: 'numeric', month: 'long', day: 'numeric' }), + '15 de enero de 2026'); + assert.equal(formatRelativeTime(-5, 'minute'), 'hace 5 minutos'); + // Formatter cache is keyed per locale: switching back must not leak es forms. + pinLocale('en'); + assert.equal(formatNumber(12345.6), '12,345.6'); +}); + +test('persistLocaleAndReload stores the choice, keeps the hash exact, strips ?lang', () => { + const writes = []; + const navigations = []; + const storage = { setItem(key, value) { writes.push([key, value]); } }; + const location = { + href: 'http://localhost:4173/?lang=es&welcome=1#cam=30.2672,-97.7431,alt=2500', + assign(url) { navigations.push(url); }, + }; + assert.equal(persistLocaleAndReload('es-MX', { location, storage }), true); + assert.deepEqual(writes, [[LOCALE_STORAGE_KEY, 'es']], 'normalized value under the versioned key'); + assert.equal(navigations.length, 1); + assert.equal(navigations[0], 'http://localhost:4173/?welcome=1#cam=30.2672,-97.7431,alt=2500'); + + // No ?lang present: the URL round-trips unchanged, hash included. + navigations.length = 0; + persistLocaleAndReload('en', { location: { href: 'http://localhost:4173/#scene=coast', assign: (u) => navigations.push(u) } }); + assert.deepEqual(navigations, ['http://localhost:4173/#scene=coast']); + + // A blocked store must not block the reload. + const blocked = { setItem() { throw new Error('SecurityError'); } }; + navigations.length = 0; + assert.equal(persistLocaleAndReload('es', { location: { href: 'http://x/?lang=es#a', assign: (u) => navigations.push(u) }, storage: blocked }), true); + assert.deepEqual(navigations, ['http://x/#a']); + + // Nothing navigable injected: no throw, no reload claim. + assert.equal(persistLocaleAndReload('es', { location: null }), false); + assert.equal(persistLocaleAndReload('es', { location: {} }), false); + + // Real-browser shape: a reloadable location MUST force the reload — assign() + // to a byte-identical URL never navigates, which stranded stored-locale + // switches (no ?lang present, hash unchanged). + const reloaded = []; + const assigned = []; + const replaced = []; + assert.equal(persistLocaleAndReload('es', { + location: { + href: 'http://localhost:4173/#scene=coast', + assign: (u) => assigned.push(u), + reload: () => reloaded.push(true), + }, + history: { replaceState: (_s, _t, u) => replaced.push(u) }, + }), true); + assert.deepEqual(replaced, ['http://localhost:4173/#scene=coast'], '?lang-free URL passes through replaceState'); + assert.equal(reloaded.length, 1, 'reload is forced'); + assert.equal(assigned.length, 0, 'assign is not used when reload exists'); +}); + +test('a plural entry lacking a category the active locale selects degrades to other (pure selection)', () => { + // Graceful degradation stays pinned after the ru/uk translation landed: + // full-parity catalogs can no longer carry a partial entry, so the + // runtime contract — entry[category] ?? entry.other in selectPluralPattern + // (src/i18n/index.js) — is exercised with a synthetic { one, other } twin + // under ru, which selects few/many for 2–4/5–20. This is the runtime half + // of the plural-superset gate in catalog.test.mjs. + const twin = Object.freeze({ one: 'ONE {count}', other: 'OTHER {count}' }); + pinLocale('ru'); + assert.equal(selectPluralPattern(twin, { count: 2 }, 'demo.key'), 'OTHER {count}', + "ru selects 'few' for 2 and the entry does not carry it: entry.other renders"); + assert.equal(selectPluralPattern(twin, { count: 5 }, 'demo.key'), 'OTHER {count}', + "ru selects 'many' for 5 and the entry does not carry it: entry.other renders"); + assert.equal(selectPluralPattern(twin, { count: 21 }, 'demo.key'), 'ONE {count}', + "21 selects 'one', which the entry carries directly"); + assert.equal(selectPluralPattern(twin, { count: 1.5 }, 'demo.key'), 'OTHER {count}', + 'fractional counts select other'); + assert.equal(selectPluralPattern(twin, {}, 'demo.key'), 'OTHER {count}', + 'a non-finite count degrades to entry.other ?? entry.one'); + pinLocale('en'); +}); + +class FakeElement { + constructor(attributes) { + this.attributes = new Map(Object.entries(attributes)); + this.textContent = ''; + } + getAttribute(name) { return this.attributes.has(name) ? this.attributes.get(name) : null; } + setAttribute(name, value) { this.attributes.set(name, String(value)); } +} + +function fakeDocumentFor(elements) { + return { + querySelectorAll(selector) { + const match = /^\[([a-z0-9-]+)\]$/.exec(selector); + if (!match) return []; + return elements.filter((element) => element.getAttribute(match[1]) !== null); + }, + }; +} + +test('applyDocumentTranslations writes all four attributes and skips unknown keys', () => { + pinLocale('en'); + const elements = [ + new FakeElement({ 'data-i18n': 'shell.title.subtitle' }), + new FakeElement({ 'data-i18n-title': 'shell.actions.clearLayers.title' }), + new FakeElement({ 'data-i18n-aria-label': 'shell.actions.share.ariaLabel' }), + new FakeElement({ 'data-i18n-placeholder': 'setup.keySetup.title' }), + new FakeElement({ 'data-i18n': 'shell.missing.key' }), + new FakeElement({ 'data-i18n': '' }), + ]; + const applied = applyDocumentTranslations(fakeDocumentFor(elements)); + assert.equal(applied, 4); + assert.equal(elements[0].textContent, 'NO PLACE LEFT BEHIND'); + assert.equal(elements[1].getAttribute('title'), 'Turn off all selected data layers'); + assert.equal(elements[2].getAttribute('aria-label'), 'Copy share link'); + assert.equal(elements[3].getAttribute('placeholder'), 'Power up the globe'); + // A key that resolves nowhere leaves the element untouched. + assert.equal(elements[4].textContent, ''); + assert.equal(elements[5].textContent, ''); + assert.equal(applyDocumentTranslations(null), 0); + assert.equal(applyDocumentTranslations({}), 0); +}); + +test('getCatalog exposes the merged, dot-prefixed registry for every shipped locale', () => { + assert.deepEqual([...CATALOG_LOCALES], ['en'], + 'the foundation ships English only; each locale PR appends its code here'); + for (const locale of CATALOG_LOCALES) { + const catalog = getCatalog(locale); + assert.ok(catalog, `catalog for ${locale}`); + assert.ok(Object.keys(catalog).length > 0); + for (const key of Object.keys(catalog)) { + assert.match(key, /^(shell|cockpit|layers|setup)\./, `${locale} key ${key}`); + } + } + // Codes without a shipped catalog stay null even though they normalize + // (locale-code hygiene): shipping is catalog-driven. An unknown locale + // stays null too. + assert.equal(getCatalog('es'), null); + assert.equal(getCatalog('fr'), null); + assert.equal(getCatalog('ru'), null); + assert.equal(getCatalog('uk'), null); + assert.equal(getCatalog('de'), null); + assert.equal(getLocale(), 'en', 'state survived the whole file'); +}); + +test('locale pair: built-in and configured pairs degrade to en-only while only English ships', () => { + // English is the only shipped catalog, so the offered set is ['en'] under + // any configuration — a pair naming anything else names an unshipped + // locale. The dedup-ordering pin (default, secondary, then en as the + // always-shipped fallback) returns with the first secondary-locale PR. + assert.deepEqual([...availableLocales()], ['en'], 'no config = built-in en-only pair'); + assert.deepEqual([...availableLocales({ defaultLocale: 'fr', secondaryLocale: 'en' })], ['en'], + 'fr has no shipped catalog: the pair degenerates'); + // Regional spellings still normalize before validation. + assert.deepEqual([...availableLocales({ defaultLocale: 'FR-fr', secondaryLocale: 'EN' })], ['en']); + // A blank field means "unset" (empty .env line) and takes its built-in default. + assert.deepEqual([...availableLocales({ defaultLocale: ' ' })], ['en']); + const pair = resolveLocalePair(); + assert.equal(pair.defaultLocale, 'en'); + assert.equal(pair.secondaryLocale, 'en'); + assert.ok(Object.isFrozen(pair) && Object.isFrozen(pair.availableLocales)); +}); + +test('locale pair: a configured pair naming an unshipped locale degrades to English resolution', () => { + const config = { defaultLocale: 'fr', secondaryLocale: 'en' }; + // fr normalizes but has no shipped catalog: the pair is unusable, the + // built-in en-only pair applies, and every resolution step yields English. + assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: null, config }), 'en'); + assert.equal(resolveLocale({ location: { search: '?lang=fr' }, storage: { getItem: () => 'fr' }, languages: null, config }), 'en'); + assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'fr' }, languages: ['fr-FR'], config }), 'en'); + assert.equal(resolveLocale({ location: { search: '?lang=en' }, storage: { getItem: () => 'en' }, languages: null, config }), 'en'); +}); + +test('locale pair: ?lang= cannot offer a locale without a shipped catalog', () => { + assert.equal( + resolveLocale({ location: { search: '?lang=fr' }, storage: { getItem: () => 'en' }, languages: null }), + 'en', + 'fr has no catalog: the override falls through to the stored preference', + ); + assert.equal( + resolveLocale({ + location: { search: '?lang=es' }, + storage: { getItem: () => 'en' }, + languages: null, + config: { defaultLocale: 'es', secondaryLocale: 'fr' }, + }), + 'en', + 'a configured pair cannot smuggle an unshipped locale into the offered set', + ); +}); + +test('locale pair: a stored preference outside the offered pair falls through', () => { + // A stored 'fr'/'es' reads as absent while those locales are unshipped; + // resolution defers to the navigator list and finally the built-in default. + assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'fr' }, languages: ['en-US'] }), 'en'); + assert.equal(resolveLocale({ location: { search: '' }, storage: { getItem: () => 'es' }, languages: null }), 'en', + '…down to the default'); +}); + +test('locale pair: invalid, unshipped, or degenerate values fall back to the built-in en-only pair (dev-warn only)', () => { + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const badPairs = [ + { defaultLocale: 'de', secondaryLocale: 'es' }, // unknown locale code + { defaultLocale: 'fr', secondaryLocale: 'fr' }, // unshipped + degenerate: same locale twice + { defaultLocale: 'en', secondaryLocale: 'en' }, // equals the built-in pair: silent, still en-only + { defaultLocale: 'english', secondaryLocale: 'ES' }, + { defaultLocale: 'en', secondaryLocale: 'es' }, // es is a known code without a shipped catalog + ]; + for (const bad of badPairs) { + const pair = resolveLocalePair(bad); + assert.equal(pair.defaultLocale, 'en', JSON.stringify(bad)); + assert.equal(pair.secondaryLocale, 'en', JSON.stringify(bad)); + assert.deepEqual([...pair.availableLocales], ['en'], JSON.stringify(bad)); + assert.equal(resolveLocale({ location: { search: '' }, storage: null, languages: null, config: bad }), 'en'); + } + } finally { + console.warn = originalWarn; + } + // import.meta.env is absent under node:test, so the DEV gate keeps the + // console quiet here; the warn fires only on the dev server. + assert.deepEqual(warnings, []); +}); diff --git a/src/i18n/index.js b/src/i18n/index.js new file mode 100644 index 000000000..3d38b3f93 --- /dev/null +++ b/src/i18n/index.js @@ -0,0 +1,348 @@ +// God's Eye View i18n core (phase 1): catalog registry, translation, and +// Intl-based formatting. English is the source/fallback catalog; a missing key +// in another locale falls back to English with a development-only warning. +// English is also the ONLY shipped catalog in this foundation change — each +// additional locale lands as a stacked follow-up PR following the recipe in +// docs/TRANSLATORS.md (catalog files + registration + CATALOG_LOCALES entry). +// Every shipped catalog is built UNCONDITIONALLY — the build cost is trivial +// and the registry stays declarative — but only locales in the configured pair +// are offered/accepted (see locale.js resolveLocalePair). +// +// APPEND-ONLY NAMESPACE REGISTRATION +// ───────────────────────────────────────────────────────────────────────────── +// A worker adding a namespace creates locales//.js (exporting +// NAMESPACE plus a default message map of namespace-relative keys), then +// appends ONE import line per locale and pushes the module into that locale's +// array below. Nothing else changes, so concurrent workers merge trivially. +// Namespace-relative keys are prefixed at registration time: shell.*, +// cockpit.*, layers.*, setup.*. +import { + DEFAULT_LOCALE, + normalizeLocale, + writeStoredLocale, +} from './locale.js'; + +import * as enShell from './locales/en/shell.js'; +import * as enCockpit from './locales/en/cockpit.js'; +import * as enLayers from './locales/en/layers.js'; +import * as enSetup from './locales/en/setup.js'; + +// Append new namespace modules here (one import + one entry per locale). +const EN_NAMESPACES = [enShell, enCockpit, enLayers, enSetup]; + +/** + * Prefix one namespace module's flat, namespace-relative keys with its + * declared namespace: { 'title.subtitle': … } + NAMESPACE 'shell' becomes + * 'shell.title.subtitle'. + * @param {{NAMESPACE: string, default?: object}} namespaceModule + * @returns {Record} Frozen prefixed message map. + */ +export function mergeNamespace(namespaceModule) { + const prefix = + typeof namespaceModule?.NAMESPACE === 'string' + ? namespaceModule.NAMESPACE.trim() + : ''; + if (!/^[a-z][a-z0-9-]*$/.test(prefix)) { + throw new Error( + `i18n namespace module must export a lowercase NAMESPACE, got ${JSON.stringify(prefix)}`, + ); + } + const merged = {}; + for (const [key, value] of Object.entries(namespaceModule.default || {})) { + merged[`${prefix}.${key}`] = value; + } + return Object.freeze(merged); +} + +/** Merge namespaces into one catalog, rejecting duplicate dot-prefixed keys. */ +function buildCatalog(namespaceModules) { + const catalog = {}; + for (const namespaceModule of namespaceModules) { + for (const [key, value] of Object.entries( + mergeNamespace(namespaceModule), + )) { + if (Object.hasOwn(catalog, key)) { + throw new Error(`i18n duplicate catalog key: ${key}`); + } + catalog[key] = value; + } + } + return Object.freeze(catalog); +} + +const CATALOGS = Object.freeze({ + [DEFAULT_LOCALE]: buildCatalog(EN_NAMESPACES), +}); + +/** + * The merged catalog for a locale (frozen; for coverage/parity tests and + * tooling — runtime lookups go through t()). + * @param {string} locale + * @returns {Record|null} + */ +export function getCatalog(locale) { + const normalized = normalizeLocale(locale); + return (normalized && CATALOGS[normalized]) || null; +} + +let currentLocale = DEFAULT_LOCALE; + +/** Active locale (any catalog locale; pair-scoped by resolveLocale at boot). */ +export function getLocale() { + return currentLocale; +} + +/** + * Switch the active locale. Pure state change: call applyDocumentLanguage / + * applyDocumentTranslations to reflect it in the DOM. + * @returns {string} The locale actually applied (normalized). + */ +export function setLocale(locale) { + const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; + if (normalized !== currentLocale) { + currentLocale = normalized; + formatterCache.clear(); + } + return currentLocale; +} + +/** Development-only missing-key warning. Off under node:test and prod builds. */ +function warnMissingKey(key, detail) { + // Vite replaces import.meta.env at build time; under node:test it is absent, + // so warnings never fire in CI — only on the dev server. + if (import.meta.env?.DEV !== true) return; + console.warn(`[i18n] missing key "${key}"${detail ? ` (${detail})` : ''}`); +} + +/** + * Pure lookup shared by t() and the parity tests: the requested locale first, + * then the English fallback catalog. + * @param {Record} catalogs Catalog map to read from. + * @param {string} locale Locale to resolve within that map. + * @param {string} key Dot-prefixed message key. + * @returns {string|object|undefined} Raw entry, or undefined when nowhere. + */ +export function resolveMessage(catalogs, locale, key) { + const source = catalogs || CATALOGS; + const normalized = normalizeLocale(locale); + if ( + normalized && + source[normalized] && + Object.hasOwn(source[normalized], key) + ) { + return source[normalized][key]; + } + const fallback = source[DEFAULT_LOCALE]; + return fallback && Object.hasOwn(fallback, key) ? fallback[key] : undefined; +} + +/** Whether the key resolves in the active locale or the English fallback. */ +export function hasMessage(key) { + return resolveMessage(CATALOGS, currentLocale, key) !== undefined; +} + +const pluralRulesCache = new Map(); + +function pluralRulesFor(locale) { + let rules = pluralRulesCache.get(locale); + if (!rules) { + rules = new Intl.PluralRules(locale); + pluralRulesCache.set(locale, rules); + } + return rules; +} + +/** Select a plural variant via Intl.PluralRules for the ACTIVE locale; + * an entry lacking the selected category degrades to entry.other. Exported + * for tests: full-parity catalogs can no longer stage a partial entry, so + * the fallback is pinned synthetically (i18n.test.mjs). */ +export function selectPluralPattern(entry, values, key) { + const count = values?.count; + if (typeof count !== 'number' || !Number.isFinite(count)) { + warnMissingKey(key, 'plural entry requires a finite {count} value'); + return entry.other ?? entry.one; + } + const category = pluralRulesFor(currentLocale).select(count); + return entry[category] ?? entry.other; +} + +const PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_]+)\}/g; + +/** Named {placeholder} interpolation. Unprovided names stay literal so a gap + * stays visible during development instead of silently vanishing. */ +function interpolate(pattern, values) { + if (!values || typeof pattern !== 'string') return pattern; + return pattern.replace(PLACEHOLDER_PATTERN, (literal, name) => + Object.hasOwn(values, name) ? String(values[name]) : literal, + ); +} + +/** + * Translate one dot-prefixed key in the active locale. Missing keys in a + * non-English locale fall back to English; keys missing everywhere return the + * key itself. Plural entries ({ one, other }) require a numeric `count` value. + * @param {string} key e.g. 'layers.clear.toast.cleared' + * @param {object} [values] Named interpolation values, e.g. { count: 3, detail: '…' } + * @returns {string} + */ +export function t(key, values) { + const entry = resolveMessage(CATALOGS, currentLocale, key); + if (entry === undefined) { + warnMissingKey(key, `not in ${currentLocale} or en catalog`); + return key; + } + if ( + currentLocale !== DEFAULT_LOCALE && + !Object.hasOwn(CATALOGS[currentLocale] || {}, key) + ) { + warnMissingKey(key, `missing in ${currentLocale}, using en fallback`); + } + const pattern = + typeof entry === 'string' ? entry : selectPluralPattern(entry, values, key); + return interpolate(pattern, values); +} + +/* + * Intl formatters are memoized per (kind, locale, options): the constructors + * are expensive and stage-3 extraction will format numbers and dates on panel + * refresh paths. The cache is cleared whenever the active locale changes. + */ +const formatterCache = new Map(); + +function cachedFormatter(kind, Constructor, options) { + const cacheKey = `${kind}|${currentLocale}|${options ? JSON.stringify(options) : ''}`; + let formatter = formatterCache.get(cacheKey); + if (!formatter) { + formatter = new Constructor(currentLocale, options); + formatterCache.set(cacheKey, formatter); + } + return formatter; +} + +/** Locale-aware number formatting (Intl.NumberFormat). */ +export function formatNumber(value, options) { + return cachedFormatter('number', Intl.NumberFormat, options).format(value); +} + +/** Locale-aware date/time formatting (Intl.DateTimeFormat). */ +export function formatDate(value, options) { + return cachedFormatter('date', Intl.DateTimeFormat, options).format(value); +} + +/** + * Locale-aware relative time (Intl.RelativeTimeFormat). `value` is the signed + * distance in `unit`, e.g. formatRelativeTime(-5, 'minute') → + * '5 minutes ago' (en) / 'hace 5 minutos' (es). + * @param {number} value Signed distance in `unit`. + * @param {string} [unit] Intl unit ('second', 'minute', 'hour', 'day', …). + * @param {object} [options] Intl.RelativeTimeFormat options (numeric, style). + */ +export function formatRelativeTime(value, unit = 'second', options) { + return cachedFormatter('relative', Intl.RelativeTimeFormat, { + numeric: 'auto', + ...options, + }).format(value, unit); +} + +/** DOM write targets for applyDocumentTranslations: textContent plus the + * three presentation attributes vision.md requires to agree with visible + * labels. Never innerHTML — translation functions return text. */ +const DOCUMENT_TRANSLATION_TARGETS = Object.freeze([ + Object.freeze({ + attribute: 'data-i18n', + apply(element, text) { + element.textContent = text; + }, + }), + Object.freeze({ + attribute: 'data-i18n-title', + apply(element, text) { + element.setAttribute('title', text); + }, + }), + Object.freeze({ + attribute: 'data-i18n-aria-label', + apply(element, text) { + element.setAttribute('aria-label', text); + }, + }), + Object.freeze({ + attribute: 'data-i18n-placeholder', + apply(element, text) { + element.setAttribute('placeholder', text); + }, + }), +]); + +/** + * Apply catalog text to every element carrying an i18n attribute in `doc`. + * Elements whose key resolves nowhere are left untouched (warned in dev). + * @param {Document} doc + * @returns {number} Count of element/attribute writes performed. + */ +export function applyDocumentTranslations(doc) { + if (typeof doc?.querySelectorAll !== 'function') return 0; + let applied = 0; + for (const target of DOCUMENT_TRANSLATION_TARGETS) { + for (const element of doc.querySelectorAll(`[${target.attribute}]`)) { + const key = element.getAttribute?.(target.attribute); + if (!key) continue; + if (!hasMessage(key)) { + warnMissingKey(key, `attribute ${target.attribute}`); + continue; + } + target.apply(element, t(key)); + applied += 1; + } + } + return applied; +} + +/** + * Persist a locale choice, then reload preserving window.location.hash exactly + * and stripping ?lang from the search string — the override is one-shot and + * must never graduate into the stored URL. Storage is best-effort: where + * storage is blocked (private mode) the preference simply will not survive. + * @param {string} locale Locale the user chose (normalized here). + * @param {object} [deps] + * @param {{href: string, assign: Function}} [deps.location] + * @param {object} [deps.storage] Injected localStorage-like store. + * @returns {boolean} true when a reload was triggered. + */ +export function persistLocaleAndReload( + locale, + { + location = globalThis.location, + history = globalThis.history, + storage, + } = {}, +) { + const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; + writeStoredLocale(normalized, storage); + if (!location || typeof location.assign !== 'function') return false; + const stripLang = () => { + try { + const url = new URL(location.href); + url.searchParams.delete('lang'); + return url.href; + } catch { + return location.href; + } + }; + // location.assign() to the byte-identical URL performs NO navigation in + // real browsers (the common case: no ?lang present, hash unchanged), so a + // stored locale change would never reload. Strip ?lang via replaceState + // (no extra history entry) and force a real reload instead. + const target = stripLang(); + try { + history?.replaceState?.(null, '', target); + } catch { + // replaceState is best-effort; the reload below still applies. + } + if (typeof location.reload === 'function') { + location.reload(); + } else { + location.assign(target); + } + return true; +} diff --git a/src/i18n/locale.js b/src/i18n/locale.js new file mode 100644 index 000000000..ea5968fea --- /dev/null +++ b/src/i18n/locale.js @@ -0,0 +1,314 @@ +// Locale identity for God's Eye View (i18n phase 1). +// +// English is the default-without-configuration, the ALWAYS-shipped fallback +// catalog, and the compatibility baseline (docs/TRANSLATORS.md). It is the +// only locale with a shipped catalog in this foundation change: additional +// locales land as stacked follow-up PRs, one per locale, each following the +// recipe in docs/TRANSLATORS.md. Which pair the app actually offers is +// configured at build/dev time through GEV_DEFAULT_LOCALE / +// GEV_SECONDARY_LOCALE (vite.config.js client defines) and resolved here — +// pair members are validated against the shipped catalogs, so while only +// English ships the pair degenerates to en-only (a single EN button). +// This module owns ONLY locale resolution and document language metadata — +// catalogs and translation helpers live in src/i18n/index.js. + +/** Durable preference key. Matches the repo convention gev::v1. */ +export const LOCALE_STORAGE_KEY = 'gev:locale:v1'; + +/** + * Locales with a shipped catalog. 'en' is the fallback catalog locale + * (DEFAULT_LOCALE); every entry here can be normalized and looked up, though + * only locales in the configured PAIR (see resolveLocalePair) are offered in + * the UI and accepted during resolution. A locale PR appends its code here + * alongside its catalogs (docs/TRANSLATORS.md) — nothing else flips shipping. + */ +export const CATALOG_LOCALES = Object.freeze(['en']); + +/** + * Locale codes normalizeLocale folds regional variants for. Kept ahead of + * shipping on purpose: normalization is locale-code hygiene ('es-MX' → 'es'), + * while SHIPPING is catalog-driven through CATALOG_LOCALES — so a locale PR + * edits only CATALOG_LOCALES plus its catalog files, and a code listed here + * without a catalog normalizes but is never offerable or resolvable. + */ +const NORMALIZABLE_LOCALES = Object.freeze(['en', 'es', 'fr', 'ru', 'uk']); + +/** Fallback catalog locale and no-config default: always English. */ +export const DEFAULT_LOCALE = 'en'; + +/** + * Built-in pair used when no env/config shapes one, or a configured one is + * unusable. English-only while English is the only shipped catalog: the + * selector degenerates to a single EN button until a secondary locale PR + * restores the built-in pair. + */ +const FALLBACK_PAIR = Object.freeze({ + defaultLocale: 'en', + secondaryLocale: 'en', +}); + +/** Document metadata per normalizable locale code. All of them are LTR. */ +export const LOCALE_METADATA = Object.freeze({ + en: Object.freeze({ dir: 'ltr' }), + es: Object.freeze({ dir: 'ltr' }), + fr: Object.freeze({ dir: 'ltr' }), + ru: Object.freeze({ dir: 'ltr' }), + uk: Object.freeze({ dir: 'ltr' }), +}); + +/** + * Map any candidate language tag to a known locale code. 'es'/'fr'/'ru'/'uk' + * and every regional variant ('es-MX', 'fr_CA', 'ru-KZ', 'uk_UA') resolve to the + * primary tag; anything unknown (or not a string) resolves to null so the + * caller's precedence chain can keep looking. Knowing a code is hygiene, not + * shipping: offering/resolving a locale additionally requires a catalog + * (CATALOG_LOCALES) — see resolveLocalePair. + * @param {*} candidate Raw locale tag (?lang= value, stored value, navigator entry). + * @returns {'en'|'es'|'fr'|'ru'|'uk'|null} + */ +export function normalizeLocale(candidate) { + if (typeof candidate !== 'string') return null; + const primary = candidate.trim().toLowerCase().split(/[-_]/)[0]; + return NORMALIZABLE_LOCALES.includes(primary) ? primary : null; +} + +/** + * Read the configured pair from the two client defines. Both spellings are + * replaced TEXTUALLY by Vite (vite.config.js `define`), so they must stay + * verbatim member expressions. Under node:test `import.meta.env` is absent + * and the first access throws; the catch turns that into "use the defaults". + * @returns {{defaultLocale?: string, secondaryLocale?: string}} + */ +function readEnvLocalePair() { + try { + return { + defaultLocale: import.meta.env.GEV_DEFAULT_LOCALE, + secondaryLocale: import.meta.env.GEV_SECONDARY_LOCALE, + }; + } catch { + return {}; + } +} + +/** A blank string means "unset" (an empty .env line), not an invalid value. */ +function orBuiltIn(value, builtIn) { + return typeof value === 'string' && value.trim() ? value : builtIn; +} + +/** Development-only diagnostics, same gate as warnMissingKey in index.js. */ +function warnInvalidPair(rawDefault, rawSecondary) { + // Vite replaces import.meta.env at build time; under node:test it is absent, + // so warnings never fire in CI — only on the dev server. + if (import.meta.env?.DEV !== true) return; + console.warn( + `[i18n] unusable locale pair ${JSON.stringify(String(rawDefault))}/` + + `${JSON.stringify(String(rawSecondary))} — offering English only`, + ); +} + +/** + * Resolve the offered locale pair. With no argument the two client defines are + * read (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE; blank means the built-in + * value); an explicit { defaultLocale, secondaryLocale } injects a pair for + * tests and embedders — injected fields never fall through to the env. + * + * SHIPPING IS CATALOG-DRIVEN: both members must name a locale in + * CATALOG_LOCALES. An unknown code, an unshipped code (a locale PR not yet + * merged), or a degenerate pair (the same locale twice) returns the built-in + * pair instead, and a dev-only console.warn explains why — except when the + * raw values already ARE the built-in pair, which is the normal unconfigured + * state and must stay silent. While English is the only shipped catalog the + * built-in pair is en-only, so the selector renders a single EN button. + * + * availableLocales is the deduped [default, secondary, 'en'] triple — English + * is always shippable because its catalog is the unconditional fallback. + * @param {{defaultLocale?: string, secondaryLocale?: string}} [config] + * @returns {{defaultLocale: string, secondaryLocale: string, availableLocales: readonly string[]}} Frozen. + */ +export function resolveLocalePair(config) { + const injected = + typeof config === 'object' && + config !== null && + (config.defaultLocale !== undefined || + config.secondaryLocale !== undefined); + const raw = injected ? config : readEnvLocalePair(); + const rawDefault = orBuiltIn(raw?.defaultLocale, FALLBACK_PAIR.defaultLocale); + const rawSecondary = orBuiltIn( + raw?.secondaryLocale, + FALLBACK_PAIR.secondaryLocale, + ); + const defaultLocale = normalizeLocale(rawDefault); + const secondaryLocale = normalizeLocale(rawSecondary); + const offerable = Boolean( + defaultLocale && + secondaryLocale && + CATALOG_LOCALES.includes(defaultLocale) && + CATALOG_LOCALES.includes(secondaryLocale), + ); + if (!offerable || defaultLocale === secondaryLocale) { + // The built-in pair itself is never a misconfiguration: only a raw value + // that had to be corrected warns on the dev server. + if ( + rawDefault !== FALLBACK_PAIR.defaultLocale || + rawSecondary !== FALLBACK_PAIR.secondaryLocale + ) { + warnInvalidPair(rawDefault, rawSecondary); + } + return pairOf(FALLBACK_PAIR.defaultLocale, FALLBACK_PAIR.secondaryLocale); + } + return pairOf(defaultLocale, secondaryLocale); +} + +/** Freeze the deduped pair + always-shipped English. */ +function pairOf(defaultLocale, secondaryLocale) { + const availableLocales = [ + ...new Set([defaultLocale, secondaryLocale, DEFAULT_LOCALE]), + ]; + return Object.freeze({ + defaultLocale, + secondaryLocale, + availableLocales: Object.freeze(availableLocales), + }); +} + +/** + * Locales the app offers and accepts, in selector order: the configured + * default, the secondary locale, then English if not already present. + * @param {{defaultLocale?: string, secondaryLocale?: string}} [config] Injectable pair (tests/embedders). + * @returns {readonly string[]} + */ +export function availableLocales(config) { + return resolveLocalePair(config).availableLocales; +} + +/** + * One-shot `?lang=` override, read from the SEARCH string only. + * + * The override is never persisted, and it must never leak into the URL hash or + * share links: it dies with the URL it arrived in (persistLocaleAndReload in + * src/i18n/index.js strips it when a choice is stored). An unsupported value — + * or a shipped locale outside the configured pair — is ignored rather than + * forced to English; the rest of the precedence chain still gets to speak. + * @param {{search?: string}|null} location + * @param {readonly string[]} availableLocales Resolved pair locales. + * @returns {string|null} + */ +function queryLocaleOverride(location, availableLocales) { + const search = location?.search; + if (!search) return null; + try { + return normalizeIfAvailable( + new URLSearchParams(search).get('lang'), + availableLocales, + ); + } catch { + return null; + } +} + +/** Normalize a candidate, keeping it only when the pair offers that locale. */ +function normalizeIfAvailable(candidate, availableLocales) { + const normalized = normalizeLocale(candidate); + return normalized && availableLocales.includes(normalized) + ? normalized + : null; +} + +/* + * STORAGE ACCESS IS LAZY AND GUARDED (same pattern as src/firstRunExperience.js): + * the `globalThis.localStorage` getter itself can throw SecurityError in Safari + * private mode or under enterprise policies, so it is only touched inside a + * try/catch at the moment of use — never in a default parameter. An injected + * `storage` (tests, embedders) short-circuits the global entirely; `undefined` + * means "use the global". + */ + +/** Read the stored preference, treating every failure as "nothing stored". + * A stored locale outside the current pair reads as absent. */ +function readStoredLocale(storage, availableLocales) { + try { + const store = storage !== undefined ? storage : globalThis.localStorage; + return normalizeIfAvailable( + store?.getItem?.(LOCALE_STORAGE_KEY), + availableLocales, + ); + } catch { + return null; + } +} + +/** + * Write the stored preference, best-effort. Never throws. The stored value is + * any known locale code (pair membership is re-checked at resolution time, so + * a choice made under one configuration — or for a locale whose catalog has + * not shipped yet — degrades gracefully to the pair's default). + * @returns {boolean} true only if the value actually landed. + */ +export function writeStoredLocale(locale, storage) { + const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; + try { + const store = storage !== undefined ? storage : globalThis.localStorage; + if (typeof store?.setItem !== 'function') return false; + store.setItem(LOCALE_STORAGE_KEY, normalized); + return true; + } catch { + // Privacy-restricted or quota-exhausted storage must never break the app. + return false; + } +} + +/** + * Resolve the active locale. Precedence (docs/TRANSLATORS.md): + * + * ?lang= → stored preference → navigator.languages → configured default + * + * Every step only accepts locales in the configured pair (which always + * includes English); each step that yields nothing (absent or out-of-pair) + * defers to the next. Without env configuration the built-in pair is en-only + * (English is the only shipped catalog), so every step resolves English — + * byte-identical to the pre-configuration resolution chain. + * @param {object} [input] + * @param {{search?: string, href?: string}|null} [input.location] Location to read ?lang= from. + * @param {object|null} [input.storage] Injected localStorage-like store; `undefined` uses the global. + * @param {readonly string[]|null} [input.languages] Browser language list. + * @param {{defaultLocale?: string, secondaryLocale?: string}} [input.config] Injectable pair (tests/embedders); omit to read the client defines. + * @returns {string} + */ +export function resolveLocale({ + location = globalThis.location, + storage, + languages = globalThis.navigator?.languages, + config, +} = {}) { + const { availableLocales: offered, defaultLocale } = + resolveLocalePair(config); + const override = queryLocaleOverride(location, offered); + if (override) return override; + const stored = readStoredLocale(storage, offered); + if (stored) return stored; + if (Array.isArray(languages)) { + for (const candidate of languages) { + const normalized = normalizeIfAvailable(candidate, offered); + if (normalized) return normalized; + } + } + return defaultLocale; +} + +/** + * Reflect the active locale on the document: `` and `` + * must always agree with the language actually being rendered. + * @param {Document} doc Document whose documentElement carries the metadata. + * @param {string} locale Locale to apply (normalized here). + * @returns {boolean} true when the metadata was written. + */ +export function applyDocumentLanguage(doc, locale = DEFAULT_LOCALE) { + const root = doc?.documentElement; + if (!root) return false; + const normalized = normalizeLocale(locale) || DEFAULT_LOCALE; + root.lang = normalized; + root.dir = ( + LOCALE_METADATA[normalized] || LOCALE_METADATA[DEFAULT_LOCALE] + ).dir; + return true; +} diff --git a/src/i18n/locales/en/cockpit.js b/src/i18n/locales/en/cockpit.js new file mode 100644 index 000000000..91a551268 --- /dev/null +++ b/src/i18n/locales/en/cockpit.js @@ -0,0 +1,560 @@ +// English catalog — cockpit namespace (phase-1 seed). +// +// Owns the first-person cockpit HUD, its Contact summary, and the briefing +// carousel. Keys are namespace-relative; the registry prefixes them with +// `cockpit.`. +export const NAMESPACE = 'cockpit'; + +export default { + // index.html #cockpit-hud section aria-label + 'hud.sectionLabel': 'Aircraft cockpit view', + // index.html #map-view-switch button label + 'exit.label': 'EXIT COCKPIT', + // index.html .cockpit-readout-label + 'readout.groundSpeed': 'GROUND SPEED', + 'readout.altitude': 'ALTITUDE', + // index.html .cockpit-context-kicker / #cockpit-context-subject (initial) + 'context.kicker': 'CONTACT', + 'context.subjectWindow': 'CONTACTS · 250 KM', + // index.html #cockpit-brief-kicker / #cockpit-brief-subtitle (initial) + 'brief.kicker': 'LIVE SIGNALS', + 'brief.subtitle': 'OBSERVED / MAPPED PINGS', + // index.html #cockpit-vision-current small label + 'vision.current': 'CURRENT', + // index.html #cockpit-radio-station (initial) + 'radio.station.ready': 'READY', + + // ── Phase-2 static markup extraction (index.html), appended ────────────── + // The shell worker owns ALL index.html static markup in phase 2 + // (docs/TRANSLATORS.md cross-surface rule); these keys land here because + // src/ui.js and src/hud.js own the surfaces' runtime half in phase 3. + + // index.html #style-indicator / #clean-view-exit (visual-style chrome) + 'presets.activeStyleLabel': 'ACTIVE STYLE', + 'display.cleanViewExitTitle': 'Return UI controls', + 'display.cleanViewExitLabel': 'EXIT CLEAN VIEW', + + // index.html control panel — Visual Presets dock tray + 'presets.toggleAriaLabel': 'Expand Visual Presets', + // Key-only: the .panel-title node shares its element with the dock-label-icon + // span, so a data-i18n write would wipe the icon; phase 3 wires this via t(). + 'presets.title': 'VISUAL PRESETS', + 'presets.pinAriaLabel': 'Pin visual presets', + 'presets.pinTitle': 'Keep visual presets open', + 'presets.styleNormalTitle': 'Show the globe without a visual filter.', + 'presets.styleNormalLabel': 'Normal', + 'presets.styleCrtTitle': + 'Emulate a green phosphor CRT with scanlines and screen curvature.', + 'presets.styleCrtLabel': 'CRT', + 'presets.styleNvgTitle': + 'Simulate night-vision goggles with green intensification and a tube vignette.', + 'presets.styleNvgLabel': 'NVG', + 'presets.styleFlirTitle': + 'Simulate FLIR-style thermal contrast. Turn up Ironbow for color.', + 'presets.styleFlirLabel': 'FLIR', + 'presets.styleAnimeTitle': + 'Apply bright cel-shaded color and illustrated outlines.', + 'presets.styleAnimeLabel': 'Anime', + 'presets.styleNoirTitle': 'Apply high-contrast monochrome film-noir grading.', + 'presets.styleNoirLabel': 'Noir', + 'presets.styleSnowTitle': + 'Add a cold, snowy whiteout treatment to the scene.', + 'presets.styleSnowLabel': 'Snow', + // Key-only: the static span text is pinned verbatim by mapStackChips.test.mjs + // (id="map-source-label">MAP SOURCE<), so no attribute can be added without + // touching that test; phase 3 owns the swap. + 'presets.mapSourceLabel': 'MAP SOURCE', + // Key-only: the chip-row tag is pinned literally by mapStackChips.test.mjs. + 'presets.mapSourceChipsAriaLabel': 'Map source', + 'presets.miniStyleLabel': 'Style', + + // index.html #pp-toggles DISPLAY rail + // Key-only: .pp-header-label text is pinned verbatim by panelStackLayout.test.mjs. + 'display.title': 'DISPLAY', + 'display.collapseTitle': 'Collapse panel', + 'display.hudToggleTitle': 'Intelligence HUD (H)', + 'display.hudLayoutLabel': 'Layout', + 'display.hudLayoutAriaLabel': 'HUD layout', + 'display.hudLayoutTactical': 'Tactical', + 'display.hudLayoutOperator': 'Operator', + 'display.hudLayoutMinimal': 'Minimal', + 'display.detectionToggleTitle': 'Detection Overlay (D)', + 'display.detectionAriaLabel': 'Detection overlay', + 'display.detectionLabel': 'DETECT', + 'display.densityLabel': 'Density', + 'display.densityAriaLabel': 'Detection label density', + 'display.allocationLabel': 'Allocation', + 'display.allocationAriaLabel': 'Detection label allocation', + 'display.allocationElastic': 'Elastic', + 'display.allocationWeighted': 'Weighted', + 'display.fadeLabel': 'Fade', + 'display.fadeAriaLabel': 'Detection fade distance', + 'display.fadeTitle': + 'World-overlay fade distance outside the keyhole as a percentage of its radius', + 'display.outsideLabel': 'Outside', + 'display.outsideAriaLabel': 'Detection opacity outside the keyhole', + 'display.outsideTitle': + 'World-overlay label and card opacity beyond the fade distance', + 'display.parametersTitle': 'PARAMETERS', + 'display.parametersCollapseTitle': 'Collapse panel', + 'display.modelsToggleTitle': + '3D aircraft — flat icons zoomed out, 3D models up close', + 'display.modelsLabel': 'Models', + 'display.modelsCoverageAriaLabel': '3D model coverage', + 'display.modelsModeProximity': 'Proximity', + 'display.modelsModeAll': 'All', + 'display.scopeToggleTitle': 'Scope — the circular viewport mask', + 'display.scopeLabel': 'Scope', + 'display.featherLabel': 'Feather', + 'display.featherTitle': + 'Scope edge feather as a percentage of the keyhole radius', + 'display.celestialToggleTitle': 'Celestial ring — reveal the full globe', + 'display.celestialLabel': 'Celestial', + 'display.cleanViewToggleTitle': 'Hide UI chrome', + 'display.cleanViewLabel': 'Clean UI', + 'display.bloomToggleTitle': 'Bloom / Glow', + 'display.bloomLabel': 'Bloom', + 'display.sharpenToggleTitle': 'Sharpening', + 'display.sharpenLabel': 'Sharpen', + + // index.html location bar (dock locations tray; ui.js owns the runtime half) + // Key-only: .location-toolbar-label shares its element with dock-label-icon. + 'location.toolbarLabel': 'LOCATION', + 'location.collapseTitle': 'Collapse panel', + 'location.pinAriaLabel': 'Pin location tray', + 'location.pinTitle': 'Keep location tray open', + // Initial mini-status values; ui.js rewrites both once a place resolves. + 'location.miniCityInitial': '📍 Location: --', + 'location.miniPoiInitial': 'Landmark: --', + 'location.searchToggleTitle': 'Search any location', + 'location.searchPlaceholder': 'Search any location...', + + // index.html cockpit HUD statics + 'hud.level': 'LEVEL', + 'hud.routeDirectionAriaLabel': 'Estimated destination direction', + // Initial idle readout; ui.js rewrites it with a live bearing. + 'hud.routeDirectionIdle': 'DEST ---°', + 'hud.visorPlane': 'OPTICAL PLANE · 01', + 'hud.visorLock': 'VISOR LOCK · ACTIVE', + 'hud.firstPerson': 'FIRST PERSON', + // Initial meta line; ui.js rewrites it from live track state. + 'hud.aircraftMetaInitial': 'LIVE TRACK · COURSE ALIGNED', + 'hud.visionGroupAriaLabel': 'Cockpit vision style', + 'hud.compassAriaLabel': 'Current aircraft heading', + 'readout.rimGroundSpeed': 'GROUND SPEED · KTS', + 'readout.rimAltitude': 'ALTITUDE · FT', + + // index.html cockpit vision controls + 'vision.previousLabel': 'PREV', + 'vision.previousAriaLabel': 'Previous cockpit vision style', + 'vision.previousTitle': 'Previous vision style', + // The NORMAL token inside these two is the style name; phase 3 splits the + // dynamic token out when the runtime half is extracted. + 'vision.currentAriaLabel': + 'Current cockpit vision style: NORMAL. Activate for next style.', + 'vision.currentTitle': 'Current style: NORMAL — click for next', + 'vision.nextLabel': 'NEXT', + 'vision.nextAriaLabel': 'Next cockpit vision style', + 'vision.nextTitle': 'Next vision style', + + // index.html view switcher + 'exit.navAriaLabel': 'View switcher', + 'exit.resetAriaLabel': 'Reset cockpit to full globe view', + 'exit.resetTitle': 'Exit cockpit and return to full globe view', + 'exit.resetLabel': 'RESET', + 'exit.ariaLabel': 'Exit cockpit view', + 'exit.title': 'Exit cockpit view', + + // index.html cockpit route card + 'route.cardAriaLabel': 'Estimated flight plan', + 'route.kicker': 'ESTIMATED FLIGHT PLAN', + 'route.statusUnavailable': 'ROUTE DATA UNAVAILABLE', + 'route.fromLabel': 'FROM', + 'route.toLabel': 'TO', + 'route.unknownEndpoint': 'UNKNOWN', + + // index.html cockpit briefing carousel + // (brief.kicker 'LIVE SIGNALS' is seeded above but is KEY-ONLY: #cockpit-brief-kicker + // shares its element with the live-dot , so no data-i18n attribute can target it.) + 'brief.carouselAriaLabel': 'Cockpit briefing carousel', + 'brief.actionsAriaLabel': 'Cockpit briefing controls', + 'brief.previousAriaLabel': 'Previous briefing page', + 'brief.previousTitle': 'Previous briefing page', + 'brief.nextAriaLabel': 'Next briefing page', + 'brief.nextTitle': 'Next briefing page', + // Title is static prose; the visible label + aria-label are CYCLE ON/OFF + // states that belong to the phase-3 runtime extraction. + 'brief.autoTitle': + 'Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.', + 'brief.collapseAriaLabel': 'Collapse cockpit briefing panel', + 'brief.collapseTitle': 'Collapse briefing panel', + 'brief.signalsAriaLabel': 'Live signals', + 'brief.newsAriaLabel': 'Latest regional news', + 'brief.localAriaLabel': 'Location-based information', + 'brief.newsAcquiring': 'ACQUIRING REGIONAL NEWS', + 'brief.localResolving': 'RESOLVING REGION', + 'brief.labelTemp': 'TEMP', + 'brief.labelWind': 'WIND', + 'brief.labelSky': 'SKY', + 'brief.labelPrecip': 'PRECIP', + 'brief.sourceNote': 'SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS', + 'brief.tabSignalsAriaLabel': 'Show Live Signals', + 'brief.tabNewsAriaLabel': 'Show Regional News', + 'brief.tabLocalAriaLabel': 'Show Local Info', + + // index.html cockpit context panel (right-rail Global Context included) + 'context.panelAriaLabel': 'Contact cockpit summary', + 'context.navAriaLabel': 'Contact navigation', + 'context.previousAriaLabel': + 'Previous — prior visited contact in the 250 km window', + 'context.previousTitle': + 'Previous — prior visited contact in the 250 km window', + 'context.nextAriaLabel': + 'Next — nearest unvisited contact in the 250 km window', + 'context.nextTitle': 'Next — nearest unvisited contact in the 250 km window', + 'context.toggleAriaLabel': 'Collapse Contact panel', + 'context.toggleTitle': 'Collapse contact panel', + 'context.qualifier': 'CONTEXT ONLY', + 'context.cohortsAriaLabel': 'Nearby cohort counts', + 'context.nearestLabel': 'NEAREST OBSERVED / MAPPED', + 'context.nearestEmpty': 'NO AVAILABLE EXAMPLE', + 'context.uncertaintyNote': 'AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR', + 'context.weatherEnableAriaLabel': 'Enable cockpit weather effects', + 'context.weatherEnableTitle': 'Enable cockpit weather effects', + 'context.panelTitle': 'CONTEXT', + 'context.collapseTitle': 'Expand panel', + 'context.modesAriaLabel': 'Context mode', + 'context.contactsTabAriaLabel': 'CONTACTS', + 'context.contactsTabTitle': + 'Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.', + 'context.contactsTabLabel': 'CONTACTS', + 'context.missionsTabLabel': 'SPACE MISSIONS', + 'context.standbyTitle': 'SELECT CONTEXT', + 'context.actionsAriaLabel': 'Contact Context actions', + 'context.cockpitEntryLabel': 'COCKPIT', + 'context.searchNearbyLabel': 'SEARCH NEARBY SITES', + 'context.tr3bAriaLabel': 'Reclassify tracked contact as TR-3B', + 'context.tr3bTitle': 'Reclassify as TR-3B', + 'context.awarenessOffTitle': 'CONTACTS CONTEXT OFF', + 'context.awarenessOffHint': + 'SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY', + 'context.rosterAriaLabel': 'Available Space Missions', + 'context.rosterTitle': 'AVAILABLE MISSIONS', + 'context.rosterHint': 'SELECT A MISSION TO INSPECT', + 'context.rosterLoading': 'LOADING 30-DAY MISSION INDEX', + 'context.radioToggleAriaLabel': 'Open compact Radio controls', + 'context.radioToggleTitle': 'Open compact Radio controls', + 'context.radioMiniGroupAriaLabel': 'Compact Radio controls', + 'context.radioMiniTitle': 'RADIO', + 'context.radioMiniReady': 'RADIO READY', + 'context.radioDetailsAriaLabel': 'Open detailed Radio controls', + 'context.radioDetailsTitle': 'Open detailed Radio controls', + 'context.radioCloseAriaLabel': 'Close compact Radio controls', + 'context.radioCloseTitle': 'Close compact Radio controls', + 'context.radioMiniEnable': 'ENABLE', + 'context.radioMiniPrevAriaLabel': 'Previous filtered radio station', + 'context.radioMiniPrevTitle': 'Previous station', + 'context.radioMiniPlayAriaLabel': 'Play selected radio station', + 'context.radioMiniPlayTitle': 'Play', + 'context.radioMiniNextAriaLabel': 'Next filtered radio station', + 'context.radioMiniNextTitle': 'Next station', + 'context.radioMiniVolumeLabel': 'VOLUME', + 'context.radioMiniVolumeAriaLabel': 'Compact Radio volume', + + // index.html cockpit utility controls + compact radio popover + 'utility.controlsAriaLabel': 'Cockpit display and Radio controls', + 'utility.displayLabel': 'DISPLAY', + 'utility.radioLabel': 'RADIO', + 'utility.displayToggleAriaLabel': 'Expand Cockpit display options', + 'utility.displayToggleTitle': 'Expand Cockpit display options', + 'utility.displayPanelAriaLabel': 'Cockpit display options', + 'utility.radioToggleAriaLabel': 'Expand Cockpit Radio controls', + 'utility.radioToggleTitle': 'Expand Cockpit Radio controls', + 'utility.radioPanelAriaLabel': 'Cockpit compact Radio controls', + 'radio.enable': 'ENABLE', + 'radio.prevAriaLabel': 'Previous filtered radio station', + 'radio.playAriaLabel': 'Play selected radio station', + 'radio.nextAriaLabel': 'Next filtered radio station', + 'radio.volumeLabel': 'VOLUME', + 'radio.volumeAriaLabel': 'Cockpit Radio volume', + + // ── Phase-3 runtime extraction (src/ui.js), appended ───────────────────── + // Values stay byte-identical to the previous English literals; existing + // tests pin many of them under the default locale. + + // _syncPanelCollapseButton(): composed panel collapse titles. {name} is the + // panel's own (already localized) .panel-title text. + 'panel.expandTitle': 'Expand {name}', + 'panel.collapseTitle': 'Collapse {name}', + // Fallback when a panel ships no .panel-title/.pp-header-label node. + 'panel.fallbackName': 'panel', + // Radio panel collapse state; the expand state reuses the layers.radio.* + // keys seeded on the static button in phase 2. + 'panel.radioCollapseTitle': 'Collapse Radio', + 'panel.radioCollapseAria': 'Collapse Radio section', + // _updateLocationMiniStatus(): runtime rewrite of the collapsed LOCATION + // readout's single-segment search fallback (initial-state keys + // location.miniCityInitial/miniPoiInitial were seeded in phase 2). + 'location.miniSearchedPlaceholder': 'Searched location', + + // ── Phase-3 runtime extraction batch 2: cockpit + Context renderers ─────── + + // syncWeatherToggle(): state-sibling keys for the runtime ON/OFF rewrite + // (context.weatherEnableAriaLabel was seeded on the static button). + 'context.weatherDisableAriaLabel': 'Disable cockpit weather effects', + 'context.weatherStateOn': 'ON', + 'context.weatherStateOff': 'OFF', + // syncTr3bToggle(): converted-state title sibling of context.tr3bTitle. + 'context.tr3bRestoreTitle': 'Restore real aircraft', + // setVisionMode(): the style token is dynamic, so the sentence is split out + // of the seeded vision.currentAriaLabel/currentTitle (whose values pin the + // NORMAL default) into {style} templates. + 'vision.styleNameNightVision': 'Night vision', + 'vision.styleNameThermal': 'Thermal', + 'vision.styleNameNoir': 'Noir', + 'vision.currentAriaTemplate': + 'Current cockpit vision style: {style}. Activate for next style.', + 'vision.currentTitleTemplate': 'Current style: {style} — click for next', + + // Cockpit signal stream (pushCockpitSignal / renderCockpitSignals). + 'signal.trackAcquired': 'TRACK ACQUIRED', + 'signal.trackDetail': '{label} · COURSE {heading}°', + 'signal.selectFlightAria': 'Select flight {title}', + 'signal.contextStandby': 'CONTEXT STANDBY', + 'signal.contextStandbyHint': 'ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS', + 'signal.contactLostTitle': 'CONTACT LOST · {subject}', + 'signal.contactLostDetail': + 'SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN', + 'signal.classMilitary': 'MILITARY FLIGHT', + 'signal.classCommercial': 'COMMERCIAL FLIGHT', + 'signal.contactCurrent': '{aircraftClass} · CURRENT', + 'signal.contactRange': '{aircraftClass} · {distance}', + 'signal.distanceUnknown': 'DISTANCE UNKNOWN', + 'signal.inputsUnknown': { + one: '{count} INPUT UNKNOWN', + other: '{count} INPUTS UNKNOWN', + }, + 'signal.sourceStatusUnavailable': 'SOURCE STATUS UNAVAILABLE', + + // updateHud(): callsign fallback + the composed aircraft meta line (each + // feed state is its own key so no state can leak English later). + 'hud.fallbackCallsign': 'AIRCRAFT', + 'hud.metaClassMilitary': 'MILITARY', + 'hud.metaClassCommercial': 'COMMERCIAL', + 'hud.metaFeedAcquiringSurface': 'ACQUIRING SURFACE', + 'hud.metaFeedSurfaceFallback': 'SURFACE FALLBACK', + 'hud.metaFeedStale': 'STALE FEED', + 'hud.metaFeedLive': 'LIVE TRACK', + 'hud.aircraftMetaTemplate': '{aircraftClass} · {feedState} · COURSE ALIGNED', + + // updateRoute(): runtime state siblings of the seeded route keys. + 'route.statusArrowEstimated': 'ARROW · ESTIMATED DIRECTION', + 'route.directionLabel': 'DEST {bearing}', + + // updateContext(): uncertainty/nearest/bearing readout states. + 'context.uncertaintyContactLost': + 'CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR', + 'context.uncertaintyInputsUnknown': { + one: '{count} INPUT UNKNOWN · NOT AN ALL-CLEAR', + other: '{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR', + }, + 'context.uncertaintyInputsCurrent': + 'AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR', + 'context.nearestTemplate': '{cohort} · {contact}', + 'context.nearestUnavailableAria': '{cohort}, Unavailable', + 'context.bearingNone': 'BRG —', + 'context.bearingAhead': 'AHEAD', + 'context.bearingSide': '{side} {angle}', + 'context.sideLeft': 'L', + 'context.sideRight': 'R', + + // setContextCollapsed()/setSignalCollapsed(): expand-state siblings. + 'context.toggleExpandAriaLabel': 'Expand Contact panel', + 'context.toggleExpandTitle': 'Expand contact panel', + 'brief.expandAriaLabel': 'Expand cockpit briefing panel', + 'brief.expandTitle': 'Expand briefing panel', + + // setBriefAutoRotate(): cycle toggle states + the ON-state help text (the + // OFF-state help is the seeded brief.autoTitle). + 'brief.autoOn': 'CYCLE ON', + 'brief.autoOff': 'CYCLE OFF', + 'brief.autoTitleOn': + 'Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.', + + // Briefing carousel pages (kicker/subtitle/source per page; the news/local + // source lines are provider attribution and stay English). + 'brief.kickerNews': 'REGIONAL NEWS', + 'brief.kickerLocal': 'LOCAL INFO', + 'brief.subtitleNews': 'LATEST LOCATION-MATCHED REPORTING', + 'brief.subtitleLocal': 'PLACE / CONDITIONS / POSITION', + + // Local position + regional brief status states. + 'brief.positionUnavailable': 'POSITION UNAVAILABLE', + 'brief.newsUnavailable': 'REGIONAL NEWS UNAVAILABLE', + 'brief.regionUnavailable': 'REGION UNAVAILABLE', + 'brief.newsEmpty': 'NO RECENT LOCATION MATCHES', + + // renderRegionalBrief(): article metadata, cloud readout, and age chips. + 'brief.metadataSourceFallback': 'SOURCE', + 'brief.articleMetaTemplate': '{domain} · {age}', + 'brief.newsSourceLine': '{source} · LOCATION QUERY', + 'brief.age.timeUnknown': 'TIME UNKNOWN', + 'brief.age.minutes': '{count}M AGO', + 'brief.age.hours': '{count}H AGO', + 'brief.age.days': '{count}D AGO', + 'brief.wind.dirUnknown': 'DIR UNKNOWN', + 'brief.cloudTemplate': 'CLOUD {pct}%', + 'brief.cloudUnknown': 'CLOUD UNKNOWN', + + // ── Phase-3 runtime extraction batch 3: toasts, loading helpers, map tray ─ + + // Panel-chrome + share/location toasts. + 'panel.layoutResetToast': + 'Panel layout updated — positions reset to new defaults', + 'share.toastCopied': 'Link copied!', + 'share.toastCopyFailed': 'Copy failed', + 'location.toastNotFound': 'Location not found', + 'location.toastSearchFailed': 'Search failed', + 'location.toastFlyToPoiFirst': 'Fly to a POI first', + 'actions.clearLayersBusyAria': 'Clearing selected data layers', + 'actions.clearLayersFailedToast': 'Selected data layers could not be cleared', + + // Global status chip notices (_handleShareTrackingRestoreStatus). + 'status.acquiring': 'ACQUIRING', + 'status.subjectFallback': 'entity', + 'status.sharedSubjectDetail': 'SHARED {subject}', + 'status.sharedFollowExpired': 'Shared {subject} follow expired', + 'status.sharedRestoreFailed': + 'Shared {subject} could not be restored — feed unavailable', + 'status.sharedUnavailable': 'Shared {subject} is unavailable', + + // Context mode user-facing action failures. + 'context.modeContext': 'Context', + 'context.modeSpaceMissions': 'Space Missions', + 'context.toastStartBlocked': + '{mode} could not start because another layer did not stop cleanly', + 'context.toastTransitionFailedContacts': + 'Contacts could not complete the requested transition; try again', + 'context.toastTransitionFailedMissions': + 'Space Missions could not complete the requested transition; try again', + 'context.toastInstallationsRefreshFailed': + 'Nearby installations could not be refreshed; try again', + 'context.toastRestoreFailed': + 'Context could not restore every layer; try again', + 'context.toastZoomToSearch': 'Zoom in to search mapped installations', + 'context.toastInstallationsRefreshed': 'Nearby installations refreshed', + 'context.toastLayerUnavailable': + 'That layer is unavailable in the current Context mode', + 'context.actionStart': 'start', + 'context.actionStop': 'stop', + 'context.toastLayerLifecycleFailed': '{layerId} could not {action} cleanly', + 'radio.toastLifecycleFailed': 'Radio could not {action} cleanly', + + // CCTV sync chip captions + calibration/availability toasts. + 'cctv.syncLoadingFrames': 'loading frames', + 'cctv.syncGridReady': 'camera grid ready', + 'cctv.toastCalibrationSaved': 'CCTV calibration saved', + 'cctv.toastCalibrationReset': 'CCTV calibration reset', + 'cctv.toastLayerUnavailable': 'CCTV layer unavailable', + + // _renderMapStackState(): status chip fallback when no stack label resolves + // (stack names themselves are keep-English provider/stack ids). + 'presets.mapStackFallback': 'MAP', + + // ── Phase-3 runtime extraction batch 4: awareness / CCTV / Radio panels ─── + // (radio.* and cctv.* state values that layers.js already seeds are reused + // cross-namespace; these are the runtime-only siblings and compositions.) + + // _applyRuntimeStaticHeaderText(): the key-only standby description span + // (both mode descriptions in one span split by a literal
in phase 2). + 'context.standbyContactsDesc': 'CONTACTS — nearest planes · vessels · sites', + 'context.standbyMissionsDesc': 'SPACE MISSIONS — launches & orbital assets', + // _syncContextRadioLauncherState(): close-state sibling of the seeded + // context.radioToggleAriaLabel ('Open compact Radio controls'). + 'context.radioToggleCloseAriaLabel': 'Close compact Radio controls', + 'context.toastMissionsCancelRestoreFailed': + 'Space Missions cancellation could not restore the previous layer state', + // Cockpit utility disclosures: collapse-state siblings of the seeded + // utility.*ToggleAriaLabel expand keys. + 'utility.displayToggleCollapseAriaLabel': 'Collapse Cockpit display options', + 'utility.radioToggleCollapseAriaLabel': 'Collapse Cockpit Radio controls', + + // Radio panel runtime states (state-sibling keys so no state can leak + // English later; lifecycle enum labels reuse layers.status.*). + 'radio.stateSync': 'SYNC', + 'radio.tunerCategoryBand': '{category} BAND', + 'radio.tunerNoStations': 'NO STATIONS', + 'radio.tunerStationAria': '{name}, station {index} of {total}', + 'radio.tunerNoStationAria': 'No station available', + 'radio.tunerOffAir': 'OFF AIR', + 'radio.tunerStationUnavailable': 'STATION UNAVAILABLE', + 'radio.actionPlay': 'Play', + 'radio.actionPause': 'Pause', + 'radio.actionResume': 'Resume', + 'radio.targetSelected': 'selected', + 'radio.targetNearest': 'nearest', + 'radio.playStateAria': '{action} {target} radio station', + 'radio.miniStateUncertain': 'RADIO STATE UNCERTAIN', + 'radio.miniSyncingDirectory': 'SYNCING DIRECTORY', + 'radio.stationSyncing': 'SYNCING', + 'radio.stationFallback': 'station', + 'radio.playbackReadyFallback': 'Ready', + + // CCTV panel runtime states (layers.cctv.* seeded keys reused where they + // match; these are the runtime-only compositions and flipped states). + 'cctv.coverageViewshedOn': 'VIEWSHED ON', + 'cctv.frameLoading': 'FRAME · LOADING', + 'cctv.frameUnavailable': 'FRAME · UNAVAILABLE', + 'cctv.calChipEdited': 'CAL · EDITED (UNSAVED)', + 'cctv.calChipTemplate': 'CAL · {badge}', + 'cctv.calBadgeCalibrated': 'CALIBRATED', + 'cctv.calBadgeCurated': 'CURATED', + 'cctv.calBadgeRawPrior': 'RAW PRIOR', + 'cctv.metaProjectionMonitor': 'MONITOR', + 'cctv.metaProjectionOff': 'OFF', + 'cctv.metaTemplate': + '{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}', + 'cctv.metaCamerasClick': { + one: '{count} camera loaded · click a camera to activate', + other: '{count} cameras loaded · click a camera to activate', + }, + 'cctv.metaCamerasEnable': { + one: '{count} camera loaded · enable CCTV to activate', + other: '{count} cameras loaded · enable CCTV to activate', + }, + 'cctv.summaryNoneAvailable': 'No summary available.', + + // ── Phase-3 runtime extraction batch 5: remainder + hud.js ──────────────── + + // _setCelestialRingEnabled(): unsupported-style title sibling of the seeded + // display.celestialToggleTitle. + 'display.celestialUnavailableTitle': + 'Celestial ring — available in Normal style', + // _updateDetectionButton(): density-profile labels (display.detectionLabel + // 'DETECT' was seeded on the static button) + composed aria. + 'display.detectionAriaTemplate': 'Detection overlay: {mode}', + 'display.detectionAriaOff': 'Detection overlay: off', + 'display.detectionLabelSparse': 'SPARSE', + 'display.detectionLabelBalanced': 'BALANCED', + 'display.detectionLabelDense': 'DENSE', + // Globe-reset buttons: idle/working aria states (cockpit variant sibling). + 'hud.resetGlobeAria': 'Reset to full globe view', + 'hud.resetGlobeCockpitAria': 'Reset cockpit to full globe view', + 'hud.resettingGlobeAria': 'Resetting to full globe view', + 'hud.resettingGlobeCockpitAria': 'Resetting cockpit to full globe view', + // _initOrbit(): indicator caption after the orbit glyph. + 'location.orbitLabel': 'ORBIT', + // Intel HUD (src/hud.js): summary caption, idle placeholder, REC indicator. + // Classification banners, terse instrument readout codes (MGRS/GSD/NIIRS/ + // ALT/COLL/ONA/BAND/BITS/LVL), and the AI summary line stay keep-English. + 'hud.summaryLabel': 'SUMMARY', + 'hud.summaryAwaiting': 'Awaiting telemetry...', + 'hud.recLabel': 'REC', + + // Upstream a11y additions (post-merge follow-up): slider/toggle accessible names. + 'display.featherAria': 'Scope edge feather', + 'display.bloomAria': 'Bloom intensity', + 'display.sharpenAria': 'Sharpen intensity', + 'location.searchAria': 'Search location by name or coordinates', + 'location.toggleAria': 'Expand LOCATION', +}; diff --git a/src/i18n/locales/en/layers.js b/src/i18n/locales/en/layers.js new file mode 100644 index 000000000..458a648f7 --- /dev/null +++ b/src/i18n/locales/en/layers.js @@ -0,0 +1,501 @@ +// English catalog — layers namespace (phase-1 seed). +// +// Owns data-layer presentation copy: display names, feed-state labels, and +// layer batch toasts. Machine-readable values (layer ids, status enum keys, +// provider names) are intentionally NOT here — see docs/TRANSLATORS.md +// for the keep-English boundary. Keys are namespace-relative; the registry +// prefixes them with `layers.`. +export const NAMESPACE = 'layers'; + +export default { + // src/data/manager.js FEED_STATE_LABELS value + 'status.unavailable': 'UNAVAILABLE', + // src/data/flights.js / src/data/earthquakes.js layer display names + 'name.liveFlights': 'Live Flights', + 'name.earthquakes': 'Earthquakes (24h)', + // src/ui.js clearSelectedLayers() result toasts (genuine plural copy) + 'clear.toast.noneSelected': 'No selected data layers', + 'clear.toast.cleared': { + one: 'Cleared {count} data layer', + other: 'Cleared {count} data layers', + }, + 'clear.toast.notCleared': { + one: '{count} data layer could not be cleared', + other: '{count} data layers could not be cleared', + }, + + // ── Phase-2 static markup extraction (index.html), appended ────────────── + // The shell worker owns ALL index.html static markup in phase 2 + // (docs/TRANSLATORS.md cross-surface rule); these keys cover the + // layers-worker surfaces (world overlay, CCTV panel, Radio panel) whose + // runtime presentation paths land here in phase 3. + + // index.html #world-overlay-actions region (src/overlays/worldOverlay.js) + 'overlay.regionAriaLabel': 'Visible map targets', + + // index.html CCTV panel chrome. Button texts marked as states (toggleOff, + // coverageOff, autoHopOff, projectionOn) are the shipped initial values; the + // phase-3 layer worker adds the flipped-state siblings. + 'cctv.panelTitle': 'CCTV', + 'cctv.collapseTitle': 'Collapse panel', + 'cctv.sourceUnknown': 'SOURCE · UNKNOWN', + 'cctv.metaIdle': 'Enable CCTV to load camera intersections', + 'cctv.toggleOff': 'CCTV OFF', + 'cctv.nearest': 'NEAREST', + 'cctv.prev': 'PREV', + 'cctv.cameraAriaLabel': 'CCTV camera', + 'cctv.next': 'NEXT', + 'cctv.focus': 'FOCUS', + 'cctv.coverageOff': 'COVERAGE OFF', + 'cctv.autoHopOff': 'AUTO HOP OFF', + 'cctv.projectionOn': 'PROJECTION ON', + 'cctv.calibrationLabel': 'CALIBRATION', + 'cctv.adjustLabel': 'ADJUST', + 'cctv.adjustTitle': + 'Drag the camera in the world: rings rotate, arrows move, handles set range/FOV', + 'cctv.calPoseAriaLabel': 'Camera pose — click a value to type', + 'cctv.calHeadingTitle': 'Heading (compass °) — click to type', + 'cctv.calPitchTitle': 'Pitch (° up/down) — click to type', + 'cctv.calFovTitle': 'Horizontal FOV (°) — click to type', + 'cctv.calRangeTitle': 'Range / monitor-plane distance (m) — click to type', + 'cctv.calHeightTitle': 'Mount height above ground (m) — click to type', + 'cctv.calNorthTitle': + 'North offset from catalog position (m) — click to type', + 'cctv.calEastTitle': 'East offset from catalog position (m) — click to type', + 'cctv.saveCal': 'SAVE CAL', + 'cctv.resetCal': 'RESET CAL', + 'cctv.summaryLabel': 'SCENE SUMMARY', + 'cctv.summaryIdle': + 'Enable CCTV to start camera-linked intelligence summaries.', + + // index.html Radio panel chrome (right rail #radio-panel) + 'radio.panelAriaLabel': 'Internet radio and local SDR', + 'radio.panelTitle': 'RADIO', + 'radio.expandTitle': 'Expand Radio', + 'radio.expandAriaLabel': 'Expand Radio section', + 'radio.enable': 'ENABLE', + 'radio.stationTagLabel': 'STATION TAG', + 'radio.filterAriaLabel': 'Filter stations by station tag', + 'radio.filterAll': 'All', + 'radio.noStation': 'NO STATION SELECTED', + 'radio.stationHint': 'Enable Radio, then choose a globe marker or use next.', + // Key-only: the #radio-tuner-band-label span text is pinned verbatim by + // radioMarkup.test.mjs, so the attribute must be wired by a later phase. + 'radio.bandLabel': 'DIRECTORY BAND', + 'radio.dragToTune': 'DRAG TO TUNE', + 'radio.tunerIdle': 'ALL · DRAG THE NEEDLE', + 'radio.snapsNote': 'SNAPS TO AVAILABLE STATIONS', + 'radio.transportAriaLabel': 'Radio playback', + 'radio.prev': 'PREV', + 'radio.prevAriaLabel': 'Previous filtered station', + 'radio.play': 'PLAY', + 'radio.playAriaLabel': 'Play selected station', + 'radio.next': 'NEXT', + 'radio.nextAriaLabel': 'Next filtered station', + 'radio.stop': 'STOP', + 'radio.stopAriaLabel': 'Stop radio playback', + 'radio.volumeLabel': 'VOLUME', + 'radio.volumeAriaLabel': 'Radio volume', + // Initial idle announcement; the runtime states land with the layers worker. + 'radio.playbackOff': 'Radio off', + 'radio.stationSite': 'STATION SITE', + 'radio.privacyNote': + 'Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.', + + // ── Phase-3 runtime extraction (src/data/*), appended ──────────────────── + // Layer display names are translated only at the presentation boundary + // (manager.js _renderToggles/_syncToggleButton map layer.id → key here). + // layer.id values, FEED_STATE_LABELS enum KEYS, and lifecycle state keys + // stay English — see docs/TRANSLATORS.md keep-English boundary. + + // src/data/manager.js FEED_STATE_LABELS values + toggle-button states. + 'status.on': 'ON', + 'status.loading': 'LOADING', + 'status.degraded': 'DEGRADED', + 'status.stale': 'STALE', + 'status.fallback': 'FALLBACK', + 'status.off': 'OFF', + 'status.partial': 'PARTIAL', + 'status.uncertain': 'UNCERTAIN', + 'status.enabling': 'ENABLING', + 'status.disabling': 'DISABLING', + + // Layer display names (toggle panel rows + toggle-button aria-label). + 'name.militaryFlights': 'Military Flights', + 'name.satellites': 'Satellites', + 'name.rocketLaunches': 'Space Missions (30d)', + 'name.traffic': 'Street Traffic', + 'name.cctv': 'CCTV', + 'name.radio': 'Radio', + 'name.bikeshare': 'Bikeshare', + 'name.aisVessels': 'Live AIS Vessels', + 'name.installations': 'Mapped Installations', + 'name.globalContext': 'Global Context', + 'name.datacenters': 'Datacenters', + 'name.dams': 'Dams', + 'name.submarineCables': 'Submarine Cables', + 'name.fires': 'FIRMS Active Fires', + + // src/data/manager.js data-panel row meta line (_buildMetaText) + time ago. + 'meta.justNow': 'just now', + 'meta.secondsAgo': '{count}s ago', + 'meta.minutesAgo': '{count}m ago', + 'meta.hoursAgo': '{count}h ago', + 'meta.never': 'never', + 'meta.loading': 'loading...', + 'meta.uncertainLifecycle': + 'UNCERTAIN · {source} · lifecycle state requires reconciliation', + 'meta.transitioning': '{state} · {source}', + 'meta.stateSourceRetry': '{state} · {source} · {detail} · retry {seconds}s', + 'meta.stateSourceDetail': '{state} · {source} · {detail}', + 'meta.stateSourceAgo': '{state} · {source} · {ago}', + 'meta.stateSourceAgoRetry': + '{state} · {source} · {ago} · retrying in {seconds}s', + 'meta.sourceLoading': '{source} · {loadingLabel}', + 'meta.sourceAgo': '{source} · {ago}', + 'meta.partialCounts': '{accepted} of {raw} records accepted', + 'meta.partialIncomplete': 'incomplete snapshot', + 'meta.partialDetail': '{state} · {source} · {detail} · {ago}', + 'meta.toggleAriaLabel': '{name}: {state}', + + // src/data/militaryFlights.js tracked readout fallback captions. + 'readout.typeUnknown': 'Type unknown', + 'readout.regUnknown': 'Reg unknown', + 'readout.operatorUnknown': 'Operator unknown', + 'readout.altUnknown': 'Alt unknown', + + // src/data/vesselLabels.js AIS type names (display only — the styling + // matcher keeps the English tokens as machine values). + 'vessel.type.fishing': 'FISHING', + 'vessel.type.towing': 'TOWING', + 'vessel.type.dredger': 'DREDGER', + 'vessel.type.diveOps': 'DIVE OPS', + 'vessel.type.military': 'MILITARY', + 'vessel.type.sailing': 'SAILING', + 'vessel.type.pleasure': 'PLEASURE', + 'vessel.type.pilot': 'PILOT', + 'vessel.type.sar': 'SAR', + 'vessel.type.tug': 'TUG', + 'vessel.type.portTender': 'PORT TENDER', + 'vessel.type.antiPollution': 'ANTI-POLLUTION', + 'vessel.type.lawEnforce': 'LAW ENFORCE', + 'vessel.type.medical': 'MEDICAL', + 'vessel.type.highSpeed': 'HIGH-SPEED', + 'vessel.type.passenger': 'PASSENGER', + 'vessel.type.cargo': 'CARGO', + 'vessel.type.tanker': 'TANKER', + 'vessel.type.other': 'OTHER', + + // src/data/aisLiveVessels.js vessel cards + selected-vessel HUD. + 'vessel.fallback': 'VESSEL', + 'vessel.posLive': 'POS: LIVE', + 'vessel.posAt': 'POS: {time}Z', + 'vessel.hudIdle': 'AIS: --', + 'vessel.hudName': 'AIS: {name}', + 'vessel.hudTypeLine': '{type} SPD: {speed} HDG: {heading}', + 'vessel.awaitingFirstPosition': 'awaiting first AIS position…', + + // index.html CCTV panel flipped-state siblings (runtime writers live in + // src/ui.js, owned by core-ui — these keys are the layers-surface half of + // the cross-surface rule in docs/TRANSLATORS.md). + 'cctv.toggleOn': 'CCTV ON', + 'cctv.coverageOn': 'COVERAGE ON', + 'cctv.autoHopOn': 'AUTO HOP ON', + 'cctv.projectionOff': 'PROJECTION OFF', + 'cctv.adjustOn': 'ADJUST ON', + + // src/data/cctv.js buildSummaryText() — the SCENE SUMMARY line. + 'cctv.summary.standingBy': + '{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE', + 'cctv.summary.empty': 'No cameras available in catalog.', + 'cctv.summary.city': '{city} CCTV', + 'cctv.summary.hdg': 'HDG {value}°', + 'cctv.summary.fov': 'FOV {value}°', + 'cctv.summary.coverage': 'COVERAGE {value}km²', + 'cctv.summary.overlap': 'OVERLAP {count} cams', + 'cctv.summary.isolated': 'ISOLATED VIEW', + 'cctv.summary.projMonitor': 'PROJ MONITOR', + 'cctv.summary.projOff': 'PROJ OFF', + 'cctv.summary.viewshed': 'VIEWSHED', + 'cctv.summary.cal': 'CAL {value}', + 'cctv.summary.src': 'SRC {value}', + 'cctv.summary.context': '{value} CONTEXT', + + // src/data/radio.js station-tag categories, genre labels, cluster badges. + 'radio.category.news': 'News', + 'radio.category.talk': 'Talk', + 'radio.category.weather': 'Weather / Emergency', + 'radio.category.publicSafety': 'Public Safety', + 'radio.category.aviationMarine': 'Aviation / Marine', + 'radio.category.trafficTransit': 'Traffic / Transit', + 'radio.category.music': 'Music', + 'radio.category.other': 'Other', + 'radio.genre.alternative': 'Alternative', + 'radio.genre.ambient': 'Ambient', + 'radio.genre.blues': 'Blues', + 'radio.genre.classical': 'Classical', + 'radio.genre.country': 'Country', + 'radio.genre.dance': 'Dance', + 'radio.genre.electronic': 'Electronic', + 'radio.genre.folk': 'Folk', + 'radio.genre.funk': 'Funk', + 'radio.genre.hipHop': 'Hip-Hop', + 'radio.genre.house': 'House', + 'radio.genre.indie': 'Indie', + 'radio.genre.jazz': 'Jazz', + 'radio.genre.latin': 'Latin', + 'radio.genre.metal': 'Metal', + 'radio.genre.oldies': 'Oldies', + 'radio.genre.pop': 'Pop', + 'radio.genre.punk': 'Punk', + 'radio.genre.rAndB': 'R&B', + 'radio.genre.reggae': 'Reggae', + 'radio.genre.rock': 'Rock', + 'radio.genre.soul': 'Soul', + 'radio.genre.techno': 'Techno', + 'radio.genre.trance': 'Trance', + 'radio.genre.world': 'World', + 'radio.cluster.news': 'NEWS', + 'radio.cluster.talk': 'TALK', + 'radio.cluster.weather': 'WEATHER', + 'radio.cluster.publicSafety': 'SAFETY', + 'radio.cluster.aviationMarine': 'AIR / SEA', + 'radio.cluster.trafficTransit': 'TRANSIT', + 'radio.cluster.music': 'MUSIC', + 'radio.cluster.other': 'OTHER', + + // Radio right-rail panel runtime states (seeded here per the ownership + // contract; src/ui.js wires the writes — core-ui). English values mirror + // the literals ui.js writes today so wiring is byte-identical under 'en'. + 'radio.noStationAvailable': 'NO STATION AVAILABLE', + 'radio.disable': 'DISABLE', + 'radio.reconcile': 'RECONCILE', + 'radio.enableAria': 'Enable Radio', + 'radio.disableAria': 'Disable Radio', + 'radio.reconcileAria': 'Reconcile Radio — lifecycle uncertain', + 'radio.meta.loading': 'Loading station directory…', + 'radio.meta.metadataOnly': 'Directory metadata only', + 'radio.meta.chooseHint': 'Choose a globe marker or use next.', + 'radio.tags': 'TAGS · {tags}', + 'radio.state.ready': 'Ready — playback starts only from your action', + 'radio.state.loading': 'Connecting directly to broadcaster…', + 'radio.state.buffering': 'Buffering broadcaster stream…', + 'radio.state.playing': 'Playing {station}', + 'radio.state.paused': 'Paused {station}', + 'radio.state.error': 'Broadcaster stream unavailable', + 'radio.state.degradedDirectory': ' · degraded directory', + 'radio.state.staleDirectory': ' · stale directory', + 'radio.state.staleDegradedDirectory': ' · stale/degraded directory', + 'radio.state.outsideFilter': ' · outside current filter', + 'radio.state.voiceMuted': ' · muted during voice interaction', + 'radio.state.voiceRestoring': ' · restoring volume after voice', + 'radio.state.staticNoAudio': ' · static indicates no broadcaster audio', + 'radio.state.tuningStatic': ' · tuning static until broadcaster starts', + 'radio.state.stationUnavailable': + 'Station unavailable after directory refresh — choose another channel', + 'radio.state.enabling': 'Radio is enabling…', + 'radio.state.disabling': 'Radio is disabling…', + 'radio.state.uncertain': + 'Radio lifecycle is uncertain — use Enable or Disable to reconcile', + + // src/data/rocketLaunches.js space-mission surface (runtime-built panel, + // roster, replay overlay, and map cards). + 'missions.launchSite': 'LAUNCH SITE', + 'missions.launchSiteValue': 'LAUNCH SITE · {site}', + 'missions.panel.selectedHeader': 'SELECTED SPACE MISSION', + 'missions.panel.showAllTitle': 'Show all missions', + 'missions.panel.deselectAria': 'Deselect mission', + 'missions.panel.mission': 'MISSION', + 'missions.panel.status': 'STATUS · ', + 'missions.panel.launchSite': 'LAUNCH SITE · ', + 'missions.panel.launchTime': 'LAUNCH TIME · ', + 'missions.panel.orbit': 'ORBIT · ', + 'missions.panel.ascentPath': 'ASCENT PATH · ', + 'missions.panel.distance': 'CURRENT DISTANCE FROM EARTH · ', + 'missions.panel.speed': 'SATELLITE SPEED · ', + 'missions.panel.payload': 'PAYLOAD', + 'missions.panel.colName': 'NAME', + 'missions.panel.colType': 'TYPE', + 'missions.panel.colDestination': 'DESTINATION', + 'missions.panel.stagesSection': 'STAGE / RE-ENTRY / RECOVERY', + 'missions.panel.colStage': 'STAGE', + 'missions.panel.colStatus': 'STATUS', + 'missions.panel.colFinalPosition': 'FINAL POSITION', + 'missions.panel.focus': 'FOCUS', + 'missions.panel.prev': 'PREV', + 'missions.panel.next': 'NEXT', + 'missions.panel.prevTitle': 'Previous mission', + 'missions.panel.nextTitle': 'Next mission', + 'missions.panel.showAll': 'SHOW ALL / DESELECT', + 'missions.roster.empty': 'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW', + 'missions.roster.count': '{count} / 30D', + 'missions.roster.dateUnavailable': 'DATE UNAVAILABLE', + 'missions.roster.unspecifiedOperator': 'UNSPECIFIED OPERATOR', + 'missions.roster.selectAria': 'Select {mission}', + 'missions.rows.additionalPayloads': '+{count} additional payload records', + 'missions.rows.classified': 'PAYLOAD DATA UNAVAILABLE', + 'missions.rows.noStageData': 'NO STAGE RE-ENTRY / RECOVERY DATA', + 'missions.rows.unspecified': 'UNSPECIFIED', + 'missions.rows.unavailable': 'UNAVAILABLE', + 'missions.rows.flightNumber': 'FLIGHT {count}', + 'missions.rows.reused': 'REUSED', + 'missions.rows.positionUnavailable': 'POSITION UNAVAILABLE', + 'missions.rows.kmDownrange': '{value} KM DOWNRANGE', + 'missions.rows.plannedPrefix': 'PLANNED · ', + 'missions.rows.suppliedTrajectory': 'SUPPLIED TRAJECTORY POINTS', + 'missions.rows.reconstructedEstimate': 'RECONSTRUCTED ESTIMATE', + 'missions.telemetry.km': '{value} KM', + 'missions.telemetry.kmPerSecond': '{value} KM/S', + 'missions.replay.start': 'REPLAY ASCENT', + 'missions.replay.startTitle': + 'Replay the estimated ascent with a following camera', + 'missions.replay.pause': 'Pause replay', + 'missions.replay.resume': 'Resume replay', + 'missions.replay.cancel': 'Cancel replay', + 'missions.replay.speedLabel': 'REPLAY SPEED', + 'missions.replay.speedAria': 'Replay speed multiplier', + 'missions.replay.countdown': 'T−{time} · {mission}', + 'missions.replay.standbyDetail': 'LAUNCH STANDBY', + 'missions.replay.liftoff': 'LIFTOFF · {mission}', + 'missions.replay.ascentReplay': 'ASCENT REPLAY · {mission}', + 'missions.replay.ascentEstimate': 'ASCENT ESTIMATE · {mission}', + 'missions.replay.recovery': 'STAGE RE-ENTRY / RECOVERY · {mission}', + 'missions.replay.orbit': 'ORBIT REPLAY · {mission}', + 'missions.replay.pausedPrefix': 'PAUSED · {title}', + 'missions.replay.phaseCountdown': 'T minus {seconds}', + 'missions.replay.phasePreparing': 'Preparing launch site', + 'missions.replay.phaseLiftoff': 'Liftoff', + 'missions.replay.phaseAscent': 'Ascent replay', + 'missions.replay.phaseOrbit': 'Orbit replay', + 'missions.replay.pausedSuffix': ', paused', + + // src/data/satellites.js dense-shell chip + src/data/satelliteClass.js + // class labels and legend blurbs. + 'satellites.denseChip': 'DENSE', + 'satellites.denseChipLoading': 'DENSE ···', + 'satellites.denseChipFailed': 'DENSE ✕', + 'satellites.denseTitleAdd': + 'Add the full Starlink broadband shell (thousands of extra points)', + 'satellites.denseTitleLoading': 'Loading the Starlink shell…', + 'satellites.denseTitleFailed': 'Starlink {detail} — click to retry', + 'satellites.loadFailed': 'load failed', + 'satellites.denseTitleActive': + 'Showing the full Starlink shell — click for the core catalog only', + 'satellites.class.station': 'STATION', + 'satellites.class.stationBlurb': + 'Crewed stations and their visiting vehicles', + 'satellites.class.nav': 'NAV', + 'satellites.class.navBlurb': 'GNSS navigation — GPS, GLONASS, Galileo', + 'satellites.class.geo': 'GEO', + 'satellites.class.geoBlurb': + 'Geostationary belt — comms and weather, fixed over the equator', + 'satellites.class.visual': 'VISUAL', + 'satellites.class.visualBlurb': + 'Brightest naked-eye objects — CelesTrak visual group', + 'satellites.class.comms': 'COMMS', + 'satellites.class.commsBlurb': + 'Broadband constellation shell — shown only in DENSE mode', + + // src/data/firmsHeatmap.js stats labels + fire/cell cards. + 'meta.refreshing': 'refreshing...', + 'firms.keyRequired': 'KEY REQUIRED', + 'firms.staleCached': 'STALE · cached {age}', + 'firms.liveUpdated': 'LIVE · updated {age}', + 'firms.underMinuteAgo': '<1m ago', + 'firms.card.fire': 'FIRE · {frp} MW', + 'firms.card.ambient': '▲ {frp} MW', + 'firms.card.fireNoun': 'FIRE', + 'firms.card.firesNoun': 'FIRES', + 'firms.card.confSuffix': '{value} conf', + 'firms.card.ageSuffix': '{value} ago', + 'firms.card.maxFrp': 'max {value} MW', + 'firms.card.newAge': 'new {value}', + 'firms.card.night': 'NIGHT', + 'firms.card.sensorUnavailable': 'sensor n/a', + 'firms.card.focusAria': 'Focus fire detection {title}, {details}', + + // src/data/traffic.js feed presentation (manager meta loadingLabel values). + 'traffic.loadingSyncing': 'syncing LIVE traffic flow', + 'traffic.liveCoverage': 'LIVE · TomTom flow · {percent}% cov', + 'traffic.simUnavailable': 'SIMULATED — traffic service unreachable', + 'traffic.simKeyless': 'SIMULATED — add TomTom key for live', + + // src/data/bikeshare.js loading labels + selected-station card. + 'bike.loadingSyncing': 'syncing {count} city feeds...', + 'bike.loadingScanning': 'scanning nearby systems...', + 'bike.card.capacity': '🚲 {bikes} avail · {docks} docks · {capacity} cap', + 'bike.warning.notInstalled': '⚠️ Not installed', + 'bike.warning.notRenting': '⚠️ Not renting', + 'bike.warning.notReturning': '⚠️ Not returning', + 'bike.stationFallback': 'Station', + 'bike.stationWithId': 'Station {id}', + + // src/data/militaryInstallations.js + installationFeedback.js. + 'installations.fallbackTitle': 'MAPPED INSTALLATION', + 'installations.loading': 'loading mapped installation context', + 'installations.feedback.reason.rateLimited': 'Overpass rate-limited', + 'installations.feedback.reason.timeout': 'Overpass timed out', + 'installations.feedback.reason.queryFailed': + 'Overpass could not complete the query', + 'installations.feedback.reason.unavailable': + 'Overpass temporarily unavailable', + 'installations.feedback.retrying': 'Retrying mapped sites…', + 'installations.feedback.fetching': 'Fetching mapped sites…', + 'installations.feedback.retryIn': '{reason} — retrying in {seconds}s', + 'installations.feedback.retryPending': '{reason} — retry pending', + 'installations.feedback.zoomIn': 'Zoom in to search mapped installations', + 'installations.feedback.cached': 'Showing cached mapped sites', + 'installations.feedback.notLoaded': 'Mapped sites not loaded', + 'installations.feedback.loaded': 'Mapped sites loaded', + + // src/data/militaryAwareness.js panel + militaryAwarenessEngine.js reasons. + 'awareness.standbyReady': 'CONTEXT READY', + 'awareness.standbyOff': 'GLOBAL CONTEXT OFF', + 'awareness.standbySelect': 'SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION', + 'awareness.standbyEnable': 'ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY', + 'awareness.controlsAria': 'Global Context navigation', + 'awareness.previous': 'PREVIOUS', + 'awareness.focus': 'FOCUS', + 'awareness.next': 'NEXT', + 'awareness.previousTitle': + 'Previous — prior visited contact in the 250 km window', + 'awareness.nextTitle': + 'Next — nearest unvisited contact in the 250 km window', + 'awareness.note': + 'Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.', + 'awareness.unavailableAria': 'Unavailable', + 'awareness.focusAria': 'Focus {label}', + 'awareness.cohort.flights': 'Flights', + 'awareness.cohort.military': 'Military flights', + 'awareness.cohort.vessels': 'AIS vessels', + 'awareness.cohort.installations': 'Mapped installations', + 'awareness.coverage.viewport': 'CURRENT VIEWPORT ONLY', + 'awareness.hdg': 'HDG {value}°', + 'awareness.brg': 'BRG {value}°', + 'awareness.brgUnknown': 'BRG —', + 'awareness.crs': 'CRS {value}°', + 'awareness.reason.feedUnavailable': 'feed unavailable', + 'awareness.reason.feedStale': 'feed stale', + 'awareness.reason.nearby': 'observed or mapped nearby context', + 'awareness.reason.none': 'no observed or mapped objects in current feeds', + + // src/data/regionalBrief.js WMO weather-code labels (cockpit copy). + 'weather.conditionsUnknown': 'CONDITIONS UNKNOWN', + 'weather.clear': 'CLEAR', + 'weather.partlyCloudy': 'PARTLY CLOUDY', + 'weather.overcast': 'OVERCAST', + 'weather.fog': 'FOG', + 'weather.drizzle': 'DRIZZLE', + 'weather.rain': 'RAIN', + 'weather.snow': 'SNOW', + 'weather.rainShowers': 'RAIN SHOWERS', + 'weather.snowShowers': 'SNOW SHOWERS', + 'weather.thunderstorm': 'THUNDERSTORM', + 'weather.mixed': 'MIXED CONDITIONS', + + 'missions.roster.keyboardHint': 'TAB PREVIEWS · ENTER / SPACE SELECTS', + + 'vessel.awaitingPositions': 'awaiting usable AIS positions…', + 'vessel.awaitingFirstMessage': 'awaiting first AIS message…', + 'missions.estDownrange': 'EST. DOWNRANGE', +}; diff --git a/src/i18n/locales/en/setup.js b/src/i18n/locales/en/setup.js new file mode 100644 index 000000000..1c52248dc --- /dev/null +++ b/src/i18n/locales/en/setup.js @@ -0,0 +1,211 @@ +// English catalog — setup namespace (phase-1 seed). +// +// Owns first-launch onboarding, in-app key setup (Provider Settings), scene +// recipes, and voice-control chrome. Keys are namespace-relative; the registry +// prefixes them with `setup.`. +export const NAMESPACE = 'setup'; + +export default { + // index.html #first-run-launcher header/title/choices + 'firstRun.kicker': 'MISSION CONTROL · FIRST LAUNCH', + 'firstRun.title': 'Choose your first view', + 'firstRun.choice.contacts': 'LIVE CONTACTS', + 'firstRun.suppress': "Don't show this again", + // index.html #key-setup-chip / #key-setup dialog + 'keySetup.chip': 'POWER UP', + 'keySetup.kicker': 'GROUND STATION · PROVIDER SETTINGS', + 'keySetup.title': 'Power up the globe', + 'keySetup.apply': 'SAVE KEYS', + // src/keySetup.js submitUpdates() status announcement + 'keySetup.status.saving': 'Saving…', + + // ── Phase-2 static markup extraction (index.html), appended ────────────── + // The shell worker owns ALL index.html static markup in phase 2 + // (docs/TRANSLATORS.md cross-surface rule); these keys cover the + // support-worker surfaces (first-run, key setup, scene director) whose + // runtime paths land here in phase 3. + + // index.html #first-run-launcher + // Owner-authored persuasive line, pinned verbatim by firstRunExperience.test.mjs + // (unspaced em dash included) — keep the exact string when translating. + 'firstRun.description': + 'It feels like a forbidden cockpit—then you realize the sources are public and the data is real.', + 'firstRun.choice.contactsSub': 'Aircraft, vessels and nearby intelligence', + 'firstRun.choice.spaceMissions': 'SPACE MISSIONS', + 'firstRun.choice.spaceMissionsSub': + 'Launches, spacecraft and orbital context', + 'firstRun.choice.explore': 'EXPLORE MANUALLY', + 'firstRun.choice.exploreSub': 'Begin with a clean globe', + 'firstRun.dismissHint': 'ESC to dismiss', + // Initial status tip; firstRunExperience.js swaps it for progress/errors. + 'firstRun.note': + 'Tip: the GEV MIC button in the dock lets you talk to the map.', + // NOT seeded: the environmental tile's / are pinned verbatim + // by firstRunExperience.test.mjs (and the title is painted from + // ENVIRONMENTAL_LABEL_CHOICE at init) — the support worker wires them in + // phase 3 together with the test update. + + // index.html #key-setup + 'keySetup.closeAriaLabel': 'Close key setup', + 'keySetup.description': + "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.", + 'keySetup.hint': 'ESC to close', + 'keySetup.note': + 'The Google Maps key buys the photorealistic planet — everything else stacks on top.', + + // index.html scene director panel chrome (src/scenes/director.js surface) + 'scenes.panelTitle': 'SCENES', + 'scenes.collapseTitle': 'Collapse panel', + 'scenes.recipeAriaLabel': 'Scene recipe', + 'scenes.new': 'NEW', + 'scenes.delete': 'DEL', + 'scenes.capture': 'CAPTURE SHOT', + 'scenes.updateShot': 'UPDATE SHOT', + 'scenes.start': 'START', + 'scenes.stop': 'STOP', + 'scenes.next': 'NEXT', + 'scenes.exportPresets': 'EXPORT PRESETS', + 'scenes.import': 'IMPORT', + 'scenes.runLog': 'RUN LOG', + 'scenes.statusReady': 'Ready', + + // ── Phase-3 runtime extraction (support surfaces), appended ─────────────── + + // src/firstRunExperience.js — mission busy lines and status/error copy. + // {detail} is the failed layer-id list (machine values, kept raw). + 'firstRun.busy.contacts': 'Starting live contacts…', + 'firstRun.busy.spaceMissions': 'Opening space missions…', + 'firstRun.busy.environmental': 'Scanning active events…', + 'firstRun.busy.working': 'Working…', + 'firstRun.status.failed': + 'Could not open that mission{detail}. Retry or explore manually.', + 'firstRun.status.storageBlocked': + 'This browser is blocking storage, so that could not be saved.', + // The environmental tile is painted at init from ENVIRONMENTAL_LABEL_CHOICE; + // the subcopy names BOTH feeds (pinned verbatim by firstRunExperience.test.mjs). + 'firstRun.choice.environmentalSub': + 'Live earthquakes and active fires, from USGS and NASA', + 'firstRun.environmentalTitle.environmental': 'ENVIRONMENTAL', + 'firstRun.environmentalTitle.earthWatch': 'EARTH WATCH', + 'firstRun.environmentalTitle.activeEvents': 'ACTIVE EVENTS', + + // src/keySetup.js — chip counter, status line, and remove confirm. + 'keySetup.chipWaiting': { + one: 'POWER UP · {count} KEY WAITING', + other: 'POWER UP · {count} KEYS WAITING', + }, + 'keySetup.chipReady': 'POWERED UP', + 'keySetup.status.saveFailed': 'Save failed ({status}).', + 'keySetup.status.saveFailedDetail': 'Save failed: {detail}', + 'keySetup.status.pasteFirst': 'Paste at least one key first.', + 'keySetup.status.saved': + 'Saved to {store}. Restarting — this page reloads itself.', + 'keySetup.status.removed': + 'Removed from {store}. Restarting — this page reloads itself.', + 'keySetup.store.pinokio': 'your app configuration', + 'keySetup.store.env': 'your local .env', + 'keySetup.confirm.remove': 'Remove this key from your saved configuration?', + + // src/mapStackChips.js — unavailable-chip tooltip and aria templates. + 'mapStack.fallbackName': 'This map stack', + 'mapStack.unavailableReason': '{label} is unavailable', + 'mapStack.unavailableAriaLabel': '{label} unavailable: {hint}', + + // src/scenes/director.js — built-in recipe DISPLAY names only. Recipe ids + // and the URL 'scene' param stay English; stored/renamed project titles are + // user data and render verbatim. + 'scenes.recipe.flightsRadar': 'Global Flights Radar', + 'scenes.recipe.orbitalWatch': 'Orbital Watch', + 'scenes.recipe.thermalThreats': 'Thermal Threat Board', + 'scenes.recipe.cityOverload': 'City Overload', + 'scenes.recipe.omnisciencePullback': 'Omniscience Pullback', + + // src/voice/gevRealtime.js — mic chrome and connection/execution STATUS + // text. Status enum keys (idle/connecting/…) are machine values; tool + // names/schemas and model-facing results stay English (keep-English + // boundary). Tier badges STD/MINI and the MIC/ON-OFF mic label stay machine + // identifiers this phase. + 'voice.status.idle': 'OFF', + 'voice.status.connecting': 'CONNECTING', + 'voice.status.listening': 'LISTENING', + 'voice.status.executing': 'EXECUTING', + 'voice.status.error': 'ERROR', + 'voice.status.sessionCostCap': 'Session ended — cost cap {cost}', + 'voice.detail.standby': 'VOICE STANDBY', + 'voice.detail.active': 'VOICE ACTIVE', + 'voice.detail.unavailable': 'VOICE UNAVAILABLE', + 'voice.detail.microphoneUnavailable': 'WebRTC microphone support unavailable', + 'voice.detail.requestingMicrophone': 'Requesting microphone', + 'voice.detail.holdSpaceTalk': 'Hold Space to talk', + 'voice.detail.releaseSpaceSend': 'Release Space to send', + 'voice.detail.askOrCommand': 'Ask or command', + 'voice.detail.voiceOff': 'Voice off', + 'voice.detail.runningCommand': 'Running command', + 'voice.detail.radioDidNotStart': 'Radio did not start', + 'voice.hint.default': + 'Hold Space to speak · tap Space to activate focused controls', + 'voice.error.sessionStart': 'Voice session could not be started.', + 'voice.error.trayTitle': 'VOICE SYSTEM ERROR', + 'voice.error.dismiss': 'DISMISS', + 'voice.error.hint': + 'Check microphone permission and network access, then try again.', + 'voice.kicker.agent': 'AI AGENT', + 'voice.kicker.control': 'VOICE CONTROL', + 'voice.tier.appliesNextSession': '{tier} applies next session', + 'voice.tier.buttonTitle': 'Voice model tier — applies next session', + 'voice.cost.buttonTitle': 'Estimated session cost', + 'voice.button.ariaLabel': + 'Voice control — activate to toggle voice; hold Space to speak', + + // ── Stage-4 repair pass: src/scenes/director.js status corpus, appended ─── + // Status/confirm lines and the default shot title. {scene}/{shot} carry + // stored project titles (user data, rendered verbatim); {mode} is the raw + // context-mode identifier (machine value, keep-English boundary). + 'scenes.status.captureCameraNotReady': + 'Cannot capture shot: camera not ready', + 'scenes.status.shotTitleDefault': 'Shot {n}', + 'scenes.status.captured': 'Captured: {scene} / {shot}', + 'scenes.status.selectShotFirst': 'Select a shot first', + 'scenes.status.updated': 'Updated: {scene} / {shot}', + 'scenes.status.deleteShotConfirm': 'Delete shot "{shot}"?', + 'scenes.status.loaded': 'Loaded: {scene} / {shot}', + 'scenes.status.cameraUnavailable': 'Camera unavailable — exit cockpit first', + 'scenes.status.noShotsToRun': 'No shots to run', + 'scenes.status.runningShot': 'Running {index}/{total}: {scene} / {shot}', + 'scenes.status.runComplete': 'Scene run complete', + 'scenes.status.runError': 'Error: {message}', + 'scenes.status.contextExitFailed': + 'Could not exit {mode} — scene layers may be refused', + + // ── Stage-4 repair pass: keySetup dev-surface copy, appended ────────────── + // src/keySetup.js buildRow() remove chrome (dev-server-only surface). + 'keySetup.row.remove': 'REMOVE', + 'keySetup.row.removeTitle': "Remove {title} from this app's saved keys", + // src/keySetupCore.mjs keySetupRequirement(): availability sentence naming + // the registry env vars (machine values stay raw). + 'keySetup.requirement': 'Needs {envVars} — add it in Provider Settings', + // src/keySetupCore.mjs KEY_SETUP_KEYS `unlocks` copy, keyed by registry id; + // en values must stay byte-identical to the registry strings. + 'keySetup.unlocks.google-maps': 'The photorealistic 3D planet + place search', + 'keySetup.unlocks.google-maps-server': + 'Places context + Street View fallback; optional separate key', + 'keySetup.unlocks.openai': 'Voice control — talk to the planet', + 'keySetup.unlocks.aisstream': 'Live ships, worldwide', + 'keySetup.unlocks.firms': 'Live active-fire detections', + 'keySetup.unlocks.tomtom': 'Real live traffic (keyless runs a simulation)', + 'keySetup.unlocks.cesium-ion': 'Bing imagery map stacks + world terrain', + 'keySetup.unlocks.opensky': + 'More flight-polling credits (anonymous works without)', + 'keySetup.unlocks.launch-library': 'Higher space-missions request allowance', + + 'scenes.status.actionFailed': 'Scene action failed', + + 'scenes.status.projectExported': 'Project exported', + + 'voice.tierNextSession': + 'Next session: {pendingId} — this session stays on {modelId}', + 'voice.tierSwitchHint': + 'Voice model: {pendingId} — click to switch to {target}; applies next session', + 'voice.costTooltip': + 'Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.', +}; diff --git a/src/i18n/locales/en/shell.js b/src/i18n/locales/en/shell.js new file mode 100644 index 000000000..838fa0bd5 --- /dev/null +++ b/src/i18n/locales/en/shell.js @@ -0,0 +1,82 @@ +// English catalog — shell namespace (phase-1 seed). +// +// Owns the global application chrome: title bar, loader status, top-center +// actions, panel titles, and the shared global status surface. Keys are +// namespace-relative; the registry in src/i18n/index.js prefixes them with +// `shell.`. Values are plain strings or { one, other } plural variant objects +// with named {placeholder} interpolation. +export const NAMESPACE = 'shell'; + +export default { + // index.html #title-bar .subtitle + 'title.subtitle': 'NO PLACE LEFT BEHIND', + // index.html .loader-status (initial paint) + 'loading.initialStatus': 'Initializing photorealistic world...', + // src/main.js init() loaderStatus writes + 'loading.status.configuring': 'Configuring viewer...', + 'loading.status.tilesUnavailable': + 'Google 3D Tiles unavailable ({detail}). Loading the keyless globe...', + 'loading.status.flying': 'Flying to Austin, TX...', + 'loading.status.restoring': 'Restoring shared view...', + // index.html #global-loading-label (initial) and + // src/loadingFeedback.js presentLoadingFeedback() labels + 'status.loadingLiveData': 'LOADING LIVE DATA', + 'status.loadComplete': 'LOAD COMPLETE', + 'status.loadFailed': 'LOAD FAILED', + 'status.loadCancelled': 'LOAD CANCELLED', + // index.html #traffic-sync-label (initial) and + // src/loadingFeedback.js reduceTrafficSyncFeedback() neutral fallback + 'status.trafficSyncing': 'syncing road network', + 'status.retryingIn': 'retrying in {seconds}s', + 'status.retryPending': 'retry pending', + // index.html #top-center-actions buttons + 'actions.clearLayers.ariaLabel': 'Clear selected data layers', + 'actions.clearLayers.title': 'Turn off all selected data layers', + 'actions.share.ariaLabel': 'Copy share link', + 'actions.resetView.ariaLabel': 'Reset to full globe view', + // index.html #data-toggles panel header + 'panels.dataLayers': 'DATA LAYERS', + // index.html .panel-collapse-btn on #data-panel + 'panels.collapseTitle': 'Collapse panel', + + // ── Phase-2 static markup extraction (index.html), appended ────────────── + // index.html #command-dock nav region + 'dock.ariaLabel': 'Navigation, voice, and visual preset controls', + // index.html #top-center-actions nav + 'actions.navAriaLabel': 'Globe actions', + 'actions.share.title': 'Copy share link', + 'actions.resetView.title': 'Reset camera and return to full globe view', + // index.html #cctv-sync-label (initial); src/ui.js setSplitFlapText() fallback + 'status.framesLoading': 'loading frames', + + // Locale selector (index.html #control-panel tray): the group container is + // static; its buttons are runtime-rendered by ui.js _initLocaleSelector() + // from the configured locale pair (keys at the end of this file). + 'locale.groupAriaLabel': 'Language', + + // ── Phase-3 runtime extraction (support worker), appended ───────────────── + // src/main.js init() loaderStatus write sites without a phase-1/2 key. + 'loading.status.tilesGoogle': 'Loading Google 3D Tiles...', + 'loading.status.tilesKeyless': 'Loading the keyless globe...', + 'loading.status.systems': 'Initializing systems...', + // src/loadingFeedback.js presentLoadingFeedback() labels beyond the phase-1 + // seeds. 'OpenStreetMap · Overpass' detail strings are provider names and + // stay verbatim at the call site. + 'status.liveDataOff': 'LIVE DATA OFF', + 'status.mappedSitesLoaded': 'MAPPED SITES LOADED', + 'status.retryingMappedSites': 'RETRYING MAPPED SITES', + 'status.fetchingMappedSites': 'FETCHING MAPPED SITES', + 'status.turningOffLiveData': 'TURNING OFF LIVE DATA', + 'status.refreshingLiveData': 'REFRESHING LIVE DATA', + + // ── Configurable locale pair (GEV_DEFAULT_LOCALE / GEV_SECONDARY_LOCALE, + // vite.config.js client defines), appended ──────────────────────────── + // ui.js _initLocaleSelector() renders one runtime button per offered + // locale and reads shell.locale..ariaLabel for its aria-label. + // Only SHIPPED locales get a key: each locale PR appends its label to every + // shipped catalog (docs/TRANSLATORS.md), so an unshipped locale can never + // render a selector button. + 'locale.en.ariaLabel': 'Switch to English', + + 'credits.closeAria': 'Close data attribution', +}; diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 497035a40..d4018c0f6 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -11,6 +11,8 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop plugins: [plugin], googleApiKey: 'browser-fixture', cesiumToken: 'ion-fixture', + defaultLocale: 'fr', + secondaryLocale: 'en', }); assert.equal(config.plugins[2], plugin); assert.equal(config.server.host, 'localhost'); @@ -30,6 +32,9 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop assert.deepEqual(config.define, { 'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"', 'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"', + // Locale pair (i18n): threaded like the other explicit browser inputs. + 'import.meta.env.GEV_DEFAULT_LOCALE': '"fr"', + 'import.meta.env.GEV_SECONDARY_LOCALE': '"en"', }); assert.equal( createBrowserViteConfig({ host: '0.0.0.0', port: '4800' }).server From 03929f1a009e1a5a590dd790bcb2e38c4725adae Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 1 Oct 2026 12:34:07 +0200 Subject: [PATCH 09/76] docs(cctv): refresh the CCTV Camera Database size in the model-specs module The dataset has grown from 12,000+ to 28,000+ camera models (v2.26.0, 75% with a field of view). The 17 vendored records are unchanged in v2.26.0. --- src/layers/cctv/modelSpecs.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index a9d12e23d..cee3e5044 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -14,8 +14,8 @@ * * 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 12,000+ camera models transcribed from manufacturer datasheets (84% with - * FOV). This module vendors the records that the currently integrated public + * of 28,000+ camera models transcribed from manufacturer datasheets (75% 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 * appear in Sarasota County FL and Sioux Falls SD open data; packs that From 284c4d46019c022a4811381c6fbc567f8cf5b229 Mon Sep 17 00:00:00 2001 From: Meir Michanie Date: Thu, 24 Sep 2026 20:03:36 +0200 Subject: [PATCH 10/76] =?UTF-8?q?feat(i18n):=20presentation=20extraction?= =?UTF-8?q?=20=E2=80=94=20t()=20call=20sites,=20data-i18n=20templates,=20l?= =?UTF-8?q?ocale=20selector?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route every application-owned presentation string through the i18n core: t() call sites across the app/layers/ui/data/voice surfaces, data-i18n* attributes in the src/ui/templates/*.html markup (applied at boot by applyDocumentTranslations from src/standalone/application.js), and the dock locale selector (runtime-rendered from the offered pair — a single EN button while only English ships). Rebased onto current main, where the extraction follows the module splits: the shell wiring now lands in the owner modules (panelChrome, visualSettings, locationNavigation, shareRestoration, shellFeedback, applicationShell) and the voice status corpus in the realtime* modules; scene status lives in director.js + playbackAdapter.js. Modules shared with the node runtime (loadingFeedback, installationFeedback, regionalModel, keySetupCore, vessels ingestion) stay environment-neutral English and localize at their browser render sites (chip maps in shellFeedback/installations controls, the weather table in cockpitBriefing, keySetupRequirementEnvVars compositions), so the package-boundary graph stays clean; the browser boundary groups now own the i18n modules they reach (scripts/package-boundaries.json). --- scripts/package-boundaries.json | 2024 +++++++++++++++-------- src/app/controls.js | 5 +- src/app/scene.js | 13 +- src/creditKeyboard.js | 4 +- src/data/militaryAwareness.test.mjs | 2 +- src/data/militaryAwarenessEngine.js | 10 +- src/data/satelliteClass.js | 35 +- src/data/vesselLabels.js | 39 + src/firstRunExperience.js | 44 +- src/hud.js | 9 +- src/hudA11y.test.mjs | 4 +- src/i18n/locales/en/layers.js | 19 +- src/i18n/markupCoverage.test.mjs | 128 ++ src/i18n/repairPass.test.mjs | 83 + src/keySetup.js | 57 +- src/keySetupCore.mjs | 13 + src/layers/awareness/panel.js | 22 +- src/layers/awareness/rendering.js | 15 +- src/layers/awareness/subject.js | 27 +- src/layers/bikeshare/controls.js | 7 +- src/layers/bikeshare/selection.js | 16 +- src/layers/cctv/headingConfidence.js | 8 +- src/layers/cctv/presentation.js | 43 +- src/layers/firms/cards.js | 6 +- src/layers/firms/model.js | 40 +- src/layers/firms/queries.js | 20 +- src/layers/flights/tracking.js | 3 +- src/layers/installations/controls.js | 61 +- src/layers/installations/rendering.js | 3 +- src/layers/launches/overlays.js | 36 +- src/layers/launches/panel.js | 65 +- src/layers/launches/paths.js | 3 +- src/layers/launches/policyHelpers.js | 12 +- src/layers/launches/replay.js | 27 +- src/layers/military/queries.js | 3 +- src/layers/military/tracking.js | 11 +- src/layers/radio/categories.js | 25 +- src/layers/satellites/controls.js | 20 +- src/layers/submarineCables/ingestion.js | 3 +- src/layers/traffic/model.js | 39 +- src/layers/vessels/cards.js | 25 +- src/layers/vessels/lifecycle.js | 13 +- src/layers/vessels/queries.js | 28 +- src/mapSourceFocus.test.mjs | 5 +- src/mapStackChips.js | 18 +- src/maps/availability.js | 9 +- src/maps/defaultSources.js | 7 +- src/scenes/director.js | 78 +- src/scenes/playbackAdapter.js | 11 +- src/standalone/application.js | 10 + src/ui/applicationShell.js | 128 +- src/ui/cctvBindings.js | 4 +- src/ui/cctvCalibration.js | 8 +- src/ui/cctvFrames.js | 8 +- src/ui/cctvPresentation.js | 72 +- src/ui/clearLayersControl.js | 6 +- src/ui/cockpitBriefing.js | 85 +- src/ui/cockpitContext.js | 47 +- src/ui/cockpitInstruments.js | 63 +- src/ui/cockpitLayout.js | 28 +- src/ui/cockpitPresentation.js | 28 +- src/ui/cockpitSignals.js | 26 +- src/ui/cockpitTrackingController.js | 17 +- src/ui/contextActions.js | 19 +- src/ui/contextBindings.js | 11 +- src/ui/contextLayerChanges.js | 33 +- src/ui/contextSubscriptions.js | 8 +- src/ui/layerPanel.js | 153 +- src/ui/locationControls.js | 35 +- src/ui/locationNavigation.js | 17 +- src/ui/mapSourceControls.js | 5 +- src/ui/panelChrome.js | 39 +- src/ui/panelPositionControls.js | 5 +- src/ui/radioBindings.js | 56 +- src/ui/radioControls.js | 12 +- src/ui/radioPresentation.js | 193 ++- src/ui/sceneControls.js | 12 +- src/ui/shareRestoration.js | 17 +- src/ui/shellElements.js | 1 + src/ui/shellFeedback.js | 63 +- src/ui/templates/cockpit.html | 154 +- src/ui/templates/command-dock.html | 57 +- src/ui/templates/context.html | 103 +- src/ui/templates/display-controls.html | 82 +- src/ui/templates/hud-loading.html | 2 +- src/ui/templates/layer-panels.html | 86 +- src/ui/templates/provider-settings.html | 16 +- src/ui/templates/scene-chrome.html | 20 +- src/ui/templates/welcome.html | 16 +- src/ui/visualSettings.js | 27 +- src/voice/control.js | 24 +- src/voice/realtimeConnection.js | 12 +- src/voice/realtimeController.js | 36 +- src/voice/realtimeCost.js | 31 +- src/voice/realtimeInput.js | 8 +- src/voice/realtimeInputPolicy.js | 6 +- src/voice/realtimeRadio.js | 3 +- src/voice/realtimeTurns.js | 9 +- 98 files changed, 3474 insertions(+), 1625 deletions(-) create mode 100644 src/i18n/markupCoverage.test.mjs create mode 100644 src/i18n/repairPass.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 276b9e0db..dfb42419f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -12,27 +12,50 @@ "src/data/inputOwnership.js", "src/data/infrastructureOverlayEntry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "application": { - "exports": ["./application"], - "modules": ["src/app/application.js"], + "exports": [ + "./application" + ], + "modules": [ + "src/app/application.js" + ], "external": [] }, "viewer": { - "exports": ["./application/viewer"], - "modules": ["src/app/viewer.js", "src/app/atmosphereCompat.js"], - "external": ["cesium"] + "exports": [ + "./application/viewer" + ], + "modules": [ + "src/app/viewer.js", + "src/app/atmosphereCompat.js" + ], + "external": [ + "cesium" + ] }, "vite-build": { "runtime": "node", - "exports": ["./build/vite", "./build/html"], - "modules": ["build/vite.js", "build/application-html.js"], - "external": ["vite-plugin-cesium"] + "exports": [ + "./build/vite", + "./build/html" + ], + "modules": [ + "build/vite.js", + "build/application-html.js" + ], + "external": [ + "vite-plugin-cesium" + ] }, "live-providers": { "runtime": "node", - "exports": ["./server/providers/live"], + "exports": [ + "./server/providers/live" + ], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -48,16 +71,24 @@ "src/data/aisWatchdog.js", "src/sources/httpBody.js" ], - "external": ["ws"] + "external": [ + "ws" + ] }, "adsb-lol-source": { - "exports": ["./sources/adsb-lol"], - "modules": ["src/data/adsbLolFallback.js"], + "exports": [ + "./sources/adsb-lol" + ], + "modules": [ + "src/data/adsbLolFallback.js" + ], "external": [] }, "place-providers": { "runtime": "node", - "exports": ["./server/providers/places"], + "exports": [ + "./server/providers/places" + ], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -75,13 +106,19 @@ "external": [] }, "place-payloads": { - "exports": ["./sources/places"], - "modules": ["src/data/placeProviderPayloads.js"], + "exports": [ + "./sources/places" + ], + "modules": [ + "src/data/placeProviderPayloads.js" + ], "external": [] }, "space-providers": { "runtime": "node", - "exports": ["./server/providers/space"], + "exports": [ + "./server/providers/space" + ], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -93,13 +130,19 @@ "external": [] }, "space-requests": { - "exports": ["./sources/space"], - "modules": ["src/data/spaceProviderRequests.js"], + "exports": [ + "./sources/space" + ], + "modules": [ + "src/data/spaceProviderRequests.js" + ], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": ["./server/providers/terrain"], + "exports": [ + "./server/providers/terrain" + ], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js" @@ -107,35 +150,59 @@ "external": [] }, "terrain-source": { - "exports": ["./sources/terrain"], - "modules": ["src/data/terrainHeightsProxy.js"], + "exports": [ + "./sources/terrain" + ], + "modules": [ + "src/data/terrainHeightsProxy.js" + ], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": ["./server/providers/traffic"], - "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], + "exports": [ + "./server/providers/traffic" + ], + "modules": [ + "server/providers/traffic.js", + "src/data/tomtomTiles.js" + ], "external": [] }, "traffic-source": { - "exports": ["./sources/traffic"], - "modules": ["src/data/tomtomTiles.js"], + "exports": [ + "./sources/traffic" + ], + "modules": [ + "src/data/tomtomTiles.js" + ], "external": [] }, "firms-csv": { - "exports": ["./sources/firms-csv"], - "modules": ["src/data/firmsCsv.js"], + "exports": [ + "./sources/firms-csv" + ], + "modules": [ + "src/data/firmsCsv.js" + ], "external": [] }, "firms-provider": { "runtime": "node", - "exports": ["./server/providers/firms"], - "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], + "exports": [ + "./server/providers/firms" + ], + "modules": [ + "server/providers/firms.js", + "src/data/firmsCsv.js" + ], "external": [] }, "gbfs-provider": { "runtime": "node", - "exports": ["./server/providers/gbfs"], + "exports": [ + "./server/providers/gbfs" + ], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -145,13 +212,19 @@ "external": [] }, "gbfs-source": { - "exports": ["./sources/gbfs"], - "modules": ["src/data/gbfsSource.js"], + "exports": [ + "./sources/gbfs" + ], + "modules": [ + "src/data/gbfsSource.js" + ], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": ["./server/providers/cctv"], + "exports": [ + "./server/providers/cctv" + ], "modules": [ "server/providers/cctv.js", "server/providers/cctv/cap.js", @@ -176,7 +249,9 @@ }, "radio-provider": { "runtime": "node", - "exports": ["./server/providers/radio"], + "exports": [ + "./server/providers/radio" + ], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -192,7 +267,9 @@ }, "overpass-provider": { "runtime": "node", - "exports": ["./server/providers/overpass"], + "exports": [ + "./server/providers/overpass" + ], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -215,7 +292,9 @@ }, "military-installations-provider": { "runtime": "node", - "exports": ["./server/providers/military-installations"], + "exports": [ + "./server/providers/military-installations" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -234,7 +313,9 @@ }, "regional-provider": { "runtime": "node", - "exports": ["./server/providers/regional"], + "exports": [ + "./server/providers/regional" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -261,7 +342,9 @@ }, "openai-provider": { "runtime": "node", - "exports": ["./server/providers/openai"], + "exports": [ + "./server/providers/openai" + ], "modules": [ "src/hudLayoutPolicy.js", "server/providers/common/rate-limit.js", @@ -289,7 +372,9 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": ["./server/standalone/key-setup"], + "exports": [ + "./server/standalone/key-setup" + ], "modules": [ "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", @@ -300,7 +385,9 @@ "external": [] }, "place-search": { - "exports": ["./search"], + "exports": [ + "./search" + ], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -321,17 +408,27 @@ "external": [] }, "panel-disclosure": { - "exports": ["./ui/panels"], - "modules": ["src/ui/panelDisclosure.js"], + "exports": [ + "./ui/panels" + ], + "modules": [ + "src/ui/panelDisclosure.js" + ], "external": [] }, "surface-keyboard": { - "exports": ["./ui/surfaces"], - "modules": ["src/ui/surfaceKeyboard.js"], + "exports": [ + "./ui/surfaces" + ], + "modules": [ + "src/ui/surfaceKeyboard.js" + ], "external": [] }, "panel-rail-layout": { - "exports": ["./ui/layout"], + "exports": [ + "./ui/layout" + ], "modules": [ "src/ui/panelRails.js", "src/ui/displayPanelScroll.js", @@ -344,7 +441,9 @@ "external": [] }, "visual-input": { - "exports": ["./ui/input"], + "exports": [ + "./ui/input" + ], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -353,12 +452,19 @@ "external": [] }, "display-controls": { - "exports": ["./ui/display"], - "modules": ["src/ui/displayControls.js"], + "exports": [ + "./ui/display" + ], + "modules": [ + "src/ui/displayControls.js" + ], "external": [] }, "visual-effects": { - "exports": ["./ui/effects", "./ui/effects/bloom"], + "exports": [ + "./ui/effects", + "./ui/effects/bloom" + ], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -371,76 +477,130 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "map-source-controls": { - "exports": ["./ui/maps"], + "exports": [ + "./ui/maps" + ], "modules": [ - "src/ui/mapSource.js", - "src/ui/mapSourceControls.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/keySetupCore.mjs", "src/mapStackChips.js", - "src/keySetupCore.mjs" + "src/ui/mapSource.js", + "src/ui/mapSourceControls.js" ], "external": [] }, "layer-panel": { - "exports": ["./ui/layers", "./ui/layers/feedback"], + "exports": [ + "./ui/layers", + "./ui/layers/feedback" + ], "modules": [ - "src/ui/layers.js", - "src/ui/layerPanel.js", - "src/ui/weatherPanel.js", - "src/ui/railCards.js", - "src/ui/railCardBlocks.js", - "src/ui/chipGroup.js", - "src/ui/rowList.js", - "src/ui/railTimeline.js", - "src/ui/clearLayersControl.js", - "src/loadingFeedback.js", - "src/data/installationFeedback.js", - "src/keySetupCore.mjs", "src/data/feedState.js", - "src/data/layerSnapshot.js" + "src/data/installationFeedback.js", + "src/data/layerSnapshot.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/keySetupCore.mjs", + "src/loadingFeedback.js", + "src/ui/chipGroup.js", + "src/ui/clearLayersControl.js", + "src/ui/layerPanel.js", + "src/ui/layers.js", + "src/ui/railCardBlocks.js", + "src/ui/railCards.js", + "src/ui/railTimeline.js", + "src/ui/rowList.js", + "src/ui/weatherPanel.js" ], "external": [] }, "location-controls": { - "exports": ["./ui/location"], + "exports": [ + "./ui/location" + ], "modules": [ + "src/app/stateChannel.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/locationStatus.js", "src/ui/location.js", "src/ui/locationControls.js", - "src/ui/locationSearch.js", - "src/locationStatus.js", - "src/app/stateChannel.js" + "src/ui/locationSearch.js" ], "external": [] }, "radio-controls": { - "exports": ["./ui/radio"], + "exports": [ + "./ui/radio" + ], "modules": [ + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/ui/radio.js", - "src/ui/radioControls.js", "src/ui/radioBindings.js", + "src/ui/radioControls.js", "src/ui/radioPresentation.js", "src/ui/radioTunerModel.js" ], "external": [] }, "cctv-controls": { - "exports": ["./ui/cctv"], + "exports": [ + "./ui/cctv" + ], "modules": [ + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/ui/cctv.js", "src/ui/cctvBindings.js", "src/ui/cctvCalibration.js", "src/ui/cctvControls.js", "src/ui/cctvFrames.js", - "src/ui/cctvVideo.js", - "src/ui/cctvPresentation.js" + "src/ui/cctvPresentation.js", + "src/ui/cctvVideo.js" ], "external": [] }, "context-controls": { - "exports": ["./ui/context", "./ui/context/policy"], + "exports": [ + "./ui/context", + "./ui/context/policy" + ], "modules": [ + "src/contextModePolicy.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/rightRailPolicy.js", "src/ui/context.js", "src/ui/contextActions.js", "src/ui/contextBindings.js", @@ -450,8 +610,6 @@ "src/ui/contextSession.js", "src/ui/contextSubscriptions.js", "src/ui/contextTransactions.js", - "src/contextModePolicy.js", - "src/rightRailPolicy.js", "src/ui/panelRailGeometry.js" ], "external": [] @@ -464,42 +622,56 @@ "./ui/cockpit/vision" ], "modules": [ + "src/cockpitMath.js", + "src/cockpitUtilityLayout.js", + "src/cockpitVisionPolicy.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", "src/ui/cockpitCamera.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", - "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", "src/ui/cockpitPresentation.js", "src/ui/cockpitSignals.js", "src/ui/cockpitTrackingController.js", - "src/cockpitMath.js", - "src/cockpitUtilityLayout.js", - "src/cockpitVisionPolicy.js", + "src/ui/displayPanelScroll.js", "src/ui/panelRailGeometry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "scene-controls": { - "exports": ["./ui/scenes"], + "exports": [ + "./ui/scenes" + ], "modules": [ - "src/ui/scenes.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/ui/sceneControls.js", - "src/ui/scenePresentation.js" + "src/ui/scenePresentation.js", + "src/ui/scenes.js" ], "external": [] }, "ui-shell": { - "exports": ["./ui/shell"], + "exports": [ + "./ui/shell" + ], "modules": [ - "src/hudLayouts.js", - "src/hudLayoutPolicy.js", - "src/cyberSonar.js", - "src/ui/radioPanelPlacement.js", "src/app/stateChannel.js", "src/bloom.js", "src/cctvFocusPolicy.js", @@ -510,13 +682,27 @@ "src/cockpitVisionPolicy.js", "src/contactsDetectionPolicy.js", "src/contextModePolicy.js", + "src/cyberSonar.js", + "src/data/cameraArrival.js", "src/data/detectionPolicy.js", + "src/data/feedState.js", "src/data/installationFeedback.js", + "src/data/layerSnapshot.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/militaryAwarenessEngine.js", "src/data/scenePick.js", + "src/hudLayoutPolicy.js", + "src/hudLayouts.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/keySetupCore.mjs", + "src/layers/localAdsb/feedNames.js", + "src/layers/weather/imageryHost.js", "src/loadingFeedback.js", "src/locationStatus.js", "src/mapStackChips.js", @@ -530,27 +716,26 @@ "src/styles/snow.js", "src/styles/surveillance.js", "src/styles/thermal.js", + "src/ui/aircraftDisplay.js", "src/ui/applicationShell.js", - "src/ui/cyberSonarControls.js", - "src/ui/locationNavigation.js", - "src/ui/cockpitCoordinator.js", - "src/ui/cameraOrientationControls.js", "src/ui/applicationShortcuts.js", + "src/ui/cameraOrientationControls.js", "src/ui/cctv.js", "src/ui/cctvBindings.js", "src/ui/cctvCalibration.js", "src/ui/cctvControls.js", "src/ui/cctvFrames.js", - "src/ui/cctvVideo.js", "src/ui/cctvPresentation.js", + "src/ui/cctvVideo.js", + "src/ui/chipGroup.js", "src/ui/clearLayersControl.js", "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", "src/ui/cockpitCamera.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", + "src/ui/cockpitCoordinator.js", "src/ui/cockpitDisplayPortal.js", - "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", @@ -566,20 +751,21 @@ "src/ui/contextSession.js", "src/ui/contextSubscriptions.js", "src/ui/contextTransactions.js", + "src/ui/cyberSonarControls.js", + "src/ui/displayBindings.js", "src/ui/displayControls.js", + "src/ui/displayPanelScroll.js", "src/ui/effects.js", "src/ui/frameRateMonitor.js", + "src/ui/layerBindings.js", "src/ui/layerPanel.js", - "src/ui/weatherPanel.js", - "src/ui/railCards.js", - "src/ui/railCardBlocks.js", - "src/ui/chipGroup.js", - "src/ui/rowList.js", - "src/ui/railTimeline.js", "src/ui/layers.js", "src/ui/leftPanelRail.js", + "src/ui/localSdrControls.js", + "src/ui/localSdrPresentation.js", "src/ui/location.js", "src/ui/locationControls.js", + "src/ui/locationNavigation.js", "src/ui/locationSearch.js", "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -594,15 +780,21 @@ "src/ui/radio.js", "src/ui/radioBindings.js", "src/ui/radioControls.js", + "src/ui/radioPanelPlacement.js", "src/ui/radioPresentation.js", "src/ui/radioTunerModel.js", + "src/ui/railCardBlocks.js", + "src/ui/railCards.js", + "src/ui/railTimeline.js", "src/ui/recordingControls.js", "src/ui/rightPanelRail.js", + "src/ui/rowList.js", "src/ui/sceneControls.js", "src/ui/scenePresentation.js", "src/ui/scenes.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", + "src/ui/shellFacade.js", "src/ui/shellFeedback.js", "src/ui/styleParameters.js", "src/ui/surfaceKeyboard.js", @@ -611,23 +803,17 @@ "src/ui/visualInput.js", "src/ui/visualPresets.js", "src/ui/visualSettings.js", - "src/worldFocus.js", - "src/data/feedState.js", - "src/data/layerSnapshot.js", - "src/ui/aircraftDisplay.js", - "src/layers/weather/imageryHost.js", - "src/ui/layerBindings.js", - "src/ui/displayBindings.js", - "src/ui/shellFacade.js", - "src/ui/localSdrControls.js", - "src/layers/localAdsb/feedNames.js", - "src/ui/localSdrPresentation.js", - "src/data/cameraArrival.js" + "src/ui/weatherPanel.js", + "src/worldFocus.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-styles": { - "exports": ["./ui/styles"], + "exports": [ + "./ui/styles" + ], "modules": [ "src/ui/styles.js", "style.css", @@ -657,7 +843,9 @@ "external": [] }, "director": { - "exports": ["./director"], + "exports": [ + "./director" + ], "modules": [ "src/director/playback.js", "src/director/clock.js", @@ -680,59 +868,71 @@ "external": [] }, "scenes": { - "exports": ["./scenes"], + "exports": [ + "./scenes" + ], "modules": [ - "src/scenes/nepalEvidencePack.js", + "src/app/stateChannel.js", + "src/bloom.js", + "src/contextModePolicy.js", + "src/data/bhoteKoshiFloodPath.js", "src/data/bhoteKoshiIncidentPlaces.js", "src/data/bhoteKoshiShotPaths.js", - "src/data/bhoteKoshiFloodPath.js", - "src/maps/catalog.js", - "src/scenes/director.js", - "src/scenes/playbackAdapter.js", - "src/director/playback.js", - "src/scenes/recipes.js", - "src/scenes/scenePolicy.js", - "src/bloom.js", - "src/app/stateChannel.js", - "src/ui/scenes.js", - "src/ui/sceneControls.js", - "src/ui/scenePresentation.js", - "src/contextModePolicy.js", - "src/director/clock.js", - "src/director/timeline.js", - "src/scenes/shotPresentation.js", - "src/scenes/packs/registry.js", - "src/scenes/packs/defaults.js", - "src/scenes/packs/nepal.js", - "src/director/document.js", - "src/scenes/project.js", - "src/director/documentFields.js", - "src/director/cameraDocument.js", + "src/data/cameraArrival.js", + "src/data/inputOwnership.js", + "src/director/authoring.js", "src/director/camera.js", - "src/scenes/cameraMotion.js", - "src/director/packs/source.js", - "src/director/packs/session.js", - "src/director/packs/geojson.js", - "src/director/packs/manifest.js", - "src/scenes/dataPacks/controller.js", - "src/scenes/dataPacks/presentation.js", - "src/scenes/dataPacks/geometry.js", + "src/director/cameraDocument.js", + "src/director/clock.js", + "src/director/document.js", + "src/director/documentFields.js", "src/director/interactions/document.js", "src/director/interactions/session.js", - "src/scenes/interactions.js", - "src/data/inputOwnership.js", + "src/director/packs/geojson.js", + "src/director/packs/manifest.js", + "src/director/packs/session.js", + "src/director/packs/source.js", + "src/director/playback.js", "src/director/sharing/bundle.js", - "src/director/sharing/preview.js", "src/director/sharing/lifetime.js", - "src/director/authoring.js", + "src/director/sharing/preview.js", + "src/director/timeline.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/maps/catalog.js", + "src/scenes/cameraMotion.js", + "src/scenes/dataPacks/controller.js", + "src/scenes/dataPacks/geometry.js", + "src/scenes/dataPacks/presentation.js", + "src/scenes/director.js", + "src/scenes/interactions.js", + "src/scenes/nepalEvidencePack.js", + "src/scenes/packs/defaults.js", + "src/scenes/packs/nepal.js", + "src/scenes/packs/registry.js", + "src/scenes/playbackAdapter.js", + "src/scenes/project.js", + "src/scenes/recipes.js", + "src/scenes/scenePolicy.js", "src/scenes/sharing.js", + "src/scenes/shotPresentation.js", + "src/ui/sceneControls.js", + "src/ui/scenePresentation.js", "src/ui/sceneSharing.js", - "src/data/cameraArrival.js" + "src/ui/scenes.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "live-observations": { - "exports": ["./sources/live"], + "exports": [ + "./sources/live" + ], "modules": [ "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -743,7 +943,9 @@ "external": [] }, "civil-flights": { - "exports": ["./layers/flights"], + "exports": [ + "./layers/flights" + ], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -754,38 +956,48 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", + "src/data/inputOwnership.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", + "src/data/modelEligibility.js", "src/data/modelVisualAnchor.js", "src/data/motionModel.js", "src/data/renderAltitude.js", "src/data/routePlausible.js", "src/data/trackedModelRegime.js", "src/data/trackingClickGesture.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/flights/controller.js", "src/layers/flights/enrichment.js", "src/layers/flights/evidence.js", "src/layers/flights/index.js", "src/layers/flights/ingestion.js", "src/layers/flights/lifecycle.js", + "src/layers/flights/modelSpec.js", "src/layers/flights/motion.js", "src/layers/flights/policy.js", "src/layers/flights/queries.js", + "src/layers/flights/recordPolicy.js", + "src/layers/flights/records.js", "src/layers/flights/rendering.js", + "src/layers/flights/snapshotRenderer.js", "src/layers/flights/state.js", "src/layers/flights/testing.js", - "src/layers/flights/tracking.js", - "src/layers/flights/records.js", - "src/layers/flights/recordPolicy.js", - "src/layers/flights/snapshotRenderer.js", - "src/layers/flights/modelSpec.js", - "src/data/inputOwnership.js", - "src/data/modelEligibility.js" + "src/layers/flights/tracking.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "military-flights": { - "exports": ["./layers/military"], + "exports": [ + "./layers/military" + ], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -796,13 +1008,21 @@ "src/data/cockpitContactDot.js", "src/data/contactMatch.js", "src/data/iconOrientation.js", + "src/data/inputOwnership.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", + "src/data/modelEligibility.js", "src/data/modelVisualAnchor.js", "src/data/motionModel.js", "src/data/renderAltitude.js", "src/data/trackedModelRegime.js", "src/data/trackingClickGesture.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/military/controller.js", "src/layers/military/index.js", "src/layers/military/ingestion.js", @@ -810,20 +1030,22 @@ "src/layers/military/motion.js", "src/layers/military/policy.js", "src/layers/military/queries.js", + "src/layers/military/recordPolicy.js", + "src/layers/military/records.js", "src/layers/military/rendering.js", + "src/layers/military/snapshotRenderer.js", "src/layers/military/state.js", "src/layers/military/testing.js", - "src/layers/military/tracking.js", - "src/layers/military/records.js", - "src/layers/military/recordPolicy.js", - "src/layers/military/snapshotRenderer.js", - "src/data/inputOwnership.js", - "src/data/modelEligibility.js" + "src/layers/military/tracking.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "aircraft-mechanics": { - "exports": ["./aircraft"], + "exports": [ + "./aircraft" + ], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -845,11 +1067,24 @@ "src/layers/aircraft/index.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "vessel-layer": { - "exports": ["./layers/vessels"], + "exports": [ + "./layers/vessels" + ], "modules": [ + "src/data/iconOrientation.js", + "src/data/inputOwnership.js", + "src/data/vesselLabels.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", "src/layers/vessels/index.js", @@ -857,32 +1092,37 @@ "src/layers/vessels/lifecycle.js", "src/layers/vessels/policy.js", "src/layers/vessels/queries.js", + "src/layers/vessels/recordPolicy.js", + "src/layers/vessels/records.js", "src/layers/vessels/rendering.js", "src/layers/vessels/selection.js", + "src/layers/vessels/snapshotRenderer.js", "src/layers/vessels/state.js", "src/layers/vessels/testing.js", - "src/layers/vessels/tracking.js", - "src/data/iconOrientation.js", - "src/data/vesselLabels.js", - "src/layers/vessels/records.js", - "src/layers/vessels/recordPolicy.js", - "src/layers/vessels/snapshotRenderer.js", - "src/data/inputOwnership.js" + "src/layers/vessels/tracking.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "earthquakes": { - "exports": ["./layers/earthquakes"], + "exports": [ + "./layers/earthquakes" + ], "modules": [ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "recent-imagery": { - "exports": ["./layers/recent-imagery"], + "exports": [ + "./layers/recent-imagery" + ], "modules": [ "src/layers/recentImagery/index.js", "src/layers/recentImagery/model.js", @@ -891,14 +1131,25 @@ "src/layers/recentImagery/thumbnails.js", "src/maps/imageryHost.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "fire-layer": { - "exports": ["./layers/firms"], + "exports": [ + "./layers/firms" + ], "modules": [ "src/data/firmsAdapt.js", "src/data/firmsLabels.js", "src/data/iconOrientation.js", + "src/data/inputOwnership.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/firms/anchors.js", "src/layers/firms/cards.js", "src/layers/firms/index.js", @@ -911,17 +1162,27 @@ "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", - "src/layers/firms/viewport.js", - "src/data/inputOwnership.js" + "src/layers/firms/viewport.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "satellites-layer": { - "exports": ["./layers/satellites"], + "exports": [ + "./layers/satellites" + ], "modules": [ + "src/data/inputOwnership.js", "src/data/issPass.js", "src/data/satelliteClass.js", "src/data/satellitePass.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/satellites/catalog.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", @@ -930,21 +1191,32 @@ "src/layers/satellites/labels.js", "src/layers/satellites/lifecycle.js", "src/layers/satellites/orbits.js", - "src/layers/satellites/records.js", "src/layers/satellites/policy.js", + "src/layers/satellites/records.js", "src/layers/satellites/rendering.js", "src/layers/satellites/source.js", "src/layers/satellites/state.js", "src/layers/satellites/testing.js", - "src/layers/satellites/tracking.js", - "src/data/inputOwnership.js" + "src/layers/satellites/tracking.js" ], - "external": ["cesium", "satellite.js"] + "external": [ + "cesium", + "satellite.js" + ] }, "launches-layer": { - "exports": ["./layers/launches"], + "exports": [ + "./layers/launches" + ], "modules": [ "src/cyberSonar.js", + "src/data/inputOwnership.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/launches/camera.js", "src/layers/launches/controls.js", "src/layers/launches/index.js", @@ -963,53 +1235,74 @@ "src/layers/launches/selection.js", "src/layers/launches/source.js", "src/layers/launches/state.js", - "src/layers/launches/testing.js", - "src/data/inputOwnership.js" + "src/layers/launches/testing.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "installations-layer": { - "exports": ["./layers/installations"], + "exports": [ + "./layers/installations" + ], "modules": [ + "src/data/bundledJson.js", "src/data/cameraSensitivity.js", + "src/data/infrastructureOverlayEntry.js", + "src/data/inputOwnership.js", "src/data/installationFeedback.js", + "src/data/local_data/osm_military_names/names.json", "src/data/militaryInstallationData.js", + "src/data/militaryNames.js", + "src/data/retryableLoad.js", + "src/data/tomtomTiles.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/installations/controls.js", "src/layers/installations/index.js", "src/layers/installations/ingestion.js", "src/layers/installations/lifecycle.js", "src/layers/installations/model.js", + "src/layers/installations/namedMarkers.js", "src/layers/installations/policy.js", "src/layers/installations/rendering.js", "src/layers/installations/selection.js", "src/layers/installations/source.js", "src/layers/installations/state.js", "src/layers/installations/viewport.js", - "src/data/inputOwnership.js", - "src/data/tomtomTiles.js", "src/sources/capability.js", "src/sources/httpBody.js", - "src/sources/openFreeMap.js", "src/sources/militaryTileGeometry.js", - "src/sources/vectorTiles.js", + "src/sources/openFreeMap.js", "src/sources/phaseTiming.js", - "src/data/militaryNames.js", "src/sources/requestDeadline.js", - "src/data/local_data/osm_military_names/names.json", - "src/data/bundledJson.js", - "src/data/retryableLoad.js", - "src/layers/installations/namedMarkers.js", - "src/data/infrastructureOverlayEntry.js" + "src/sources/vectorTiles.js" ], - "external": ["cesium", "@mapbox/vector-tile", "pbf"] + "external": [ + "cesium", + "@mapbox/vector-tile", + "pbf" + ] }, "awareness-layer": { - "exports": ["./layers/awareness"], + "exports": [ + "./layers/awareness" + ], "modules": [ - "src/cyberSonar.js", "src/cockpitMath.js", + "src/cyberSonar.js", "src/data/iconOrientation.js", "src/data/militaryAwarenessEngine.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/awareness/controls.js", "src/layers/awareness/dependencies.js", "src/layers/awareness/focus.js", @@ -1028,10 +1321,14 @@ "src/layers/awareness/testing.js", "src/ui/panelRailGeometry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "traffic-layer": { - "exports": ["./layers/traffic"], + "exports": [ + "./layers/traffic" + ], "modules": [ "src/data/cameraSensitivity.js", "src/data/flowMatch.js", @@ -1040,6 +1337,12 @@ "src/data/trafficFlowStyle.js", "src/data/trafficPresetStyle.js", "src/data/trafficQueue.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/traffic/animation.js", "src/layers/traffic/controls.js", "src/layers/traffic/flow.js", @@ -1048,32 +1351,46 @@ "src/layers/traffic/footprint.js", "src/layers/traffic/index.js", "src/layers/traffic/ingestion.js", - "src/sources/requestDeadline.js", "src/layers/traffic/lifecycle.js", "src/layers/traffic/model.js", "src/layers/traffic/policy.js", "src/layers/traffic/rendering.js", "src/layers/traffic/retention.js", + "src/layers/traffic/roadModes.js", "src/layers/traffic/source.js", "src/layers/traffic/state.js", "src/layers/traffic/style.js", "src/layers/traffic/surface.js", "src/layers/traffic/timing.js", "src/layers/traffic/viewport.js", - "src/sources/overpassRoads.js", "src/sources/capability.js", "src/sources/httpBody.js", - "src/sources/openFreeMap.js", "src/sources/militaryTileGeometry.js", - "src/sources/vectorTiles.js", + "src/sources/openFreeMap.js", + "src/sources/overpassRoads.js", "src/sources/phaseTiming.js", - "src/layers/traffic/roadModes.js" + "src/sources/requestDeadline.js", + "src/sources/vectorTiles.js" ], - "external": ["cesium", "pbf", "@mapbox/vector-tile"] + "external": [ + "cesium", + "pbf", + "@mapbox/vector-tile" + ] }, "bikeshare-layer": { - "exports": ["./layers/bikeshare"], + "exports": [ + "./layers/bikeshare" + ], "modules": [ + "src/data/cameraSensitivity.js", + "src/data/inputOwnership.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/bikeshare/controls.js", "src/layers/bikeshare/index.js", "src/layers/bikeshare/ingestion.js", @@ -1087,21 +1404,31 @@ "src/layers/bikeshare/source.js", "src/layers/bikeshare/state.js", "src/layers/bikeshare/testing.js", - "src/layers/bikeshare/viewport.js", - "src/data/inputOwnership.js", - "src/data/cameraSensitivity.js" + "src/layers/bikeshare/viewport.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "cctv-layer": { - "exports": ["./layers/cctv"], + "exports": [ + "./layers/cctv" + ], "modules": [ "src/data/cctvCards.js", + "src/data/cctvFootprint.js", "src/data/cctvGizmo.js", "src/data/cctvLod.js", "src/data/cctvViewshed.js", "src/data/iconOrientation.js", + "src/data/inputOwnership.js", "src/data/trackingClickGesture.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/cctv/calibration.js", "src/layers/cctv/cards.js", "src/layers/cctv/catalog.js", @@ -1121,21 +1448,24 @@ "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", "src/layers/cctv/projection.js", - "src/layers/cctv/videoPlayback.js", "src/layers/cctv/rendering.js", "src/layers/cctv/selection.js", "src/layers/cctv/source.js", + "src/layers/cctv/sourcePolicy.js", "src/layers/cctv/state.js", "src/layers/cctv/testing.js", - "src/overlays/worldOverlayTokens.js", - "src/data/cctvFootprint.js", - "src/data/inputOwnership.js", - "src/layers/cctv/sourcePolicy.js" + "src/layers/cctv/videoPlayback.js", + "src/overlays/worldOverlayTokens.js" ], - "external": ["hls.js", "cesium"] + "external": [ + "hls.js", + "cesium" + ] }, "alpr-cameras": { - "exports": ["./layers/alpr"], + "exports": [ + "./layers/alpr" + ], "modules": [ "src/layers/alpr/index.js", "src/layers/alpr/model.js", @@ -1154,15 +1484,28 @@ "src/sources/phaseTiming.js", "src/layers/alpr/floors.js" ], - "external": ["cesium", "@mapbox/vector-tile", "pbf"] + "external": [ + "cesium", + "@mapbox/vector-tile", + "pbf" + ] }, "radio-layer": { - "exports": ["./layers/radio"], + "exports": [ + "./layers/radio" + ], "modules": [ "src/data/iconOrientation.js", + "src/data/inputOwnership.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/radioCountry.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/radio/catalogModel.js", "src/layers/radio/categories.js", "src/layers/radio/clustering.js", @@ -1185,14 +1528,24 @@ "src/layers/radio/tuning.js", "src/layers/radio/tuningNoise.js", "src/layers/radio/volume.js", - "src/ui/radioTunerModel.js", - "src/data/inputOwnership.js" + "src/ui/radioTunerModel.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "submarine-cables": { - "exports": ["./layers/submarine-cables"], + "exports": [ + "./layers/submarine-cables" + ], "modules": [ + "src/data/inputOwnership.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", "src/layers/submarineCables/ingestion.js", @@ -1202,13 +1555,16 @@ "src/layers/submarineCables/policy.js", "src/layers/submarineCables/rendering.js", "src/layers/submarineCables/state.js", - "src/layers/submarineCables/surface.js", - "src/data/inputOwnership.js" + "src/layers/submarineCables/surface.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "bundled-cables": { - "exports": ["./sources/bundled-cables"], + "exports": [ + "./sources/bundled-cables" + ], "modules": [ "src/layers/submarineCables/bundledSource.js", "src/data/local_data/telegeography_submarine_cables/cable-geo.json", @@ -1217,7 +1573,10 @@ "external": [] }, "geography": { - "exports": ["./geography/natural-earth", "./geography/neighborhoods"], + "exports": [ + "./geography/natural-earth", + "./geography/neighborhoods" + ], "modules": [ "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", @@ -1239,21 +1598,29 @@ "./maps/defaults" ], "modules": [ + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/keySetupCore.mjs", "src/maps/availability.js", "src/maps/catalog.js", "src/maps/controller.js", - "src/maps/imageryComparison.js", "src/maps/credits.js", "src/maps/defaultSources.js", "src/maps/google3d.js", "src/maps/imagery.js", + "src/maps/imageryComparison.js", + "src/maps/imageryHost.js", "src/maps/registry.js", "src/maps/terrain.js", - "src/maps/terrainRetry.js", - "src/keySetupCore.mjs", - "src/maps/imageryHost.js" + "src/maps/terrainRetry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "voice-connection": { "exports": [ @@ -1265,27 +1632,33 @@ "./voice/realtime-session" ], "modules": [ - "src/voice/realtimeController.js", - "src/voice/realtimeBackend.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/voice/commands.js", "src/voice/control.js", "src/voice/layerSummary.js", - "src/voice/voiceCost.js", - "src/voice/commands.js", - "src/voice/session.js", - "src/voice/sessionCommands.js", - "src/voice/realtimeSession.js", + "src/voice/realtimeBackend.js", + "src/voice/realtimeConnection.js", + "src/voice/realtimeController.js", + "src/voice/realtimeCost.js", + "src/voice/realtimeDiagnostics.js", "src/voice/realtimeEvents.js", + "src/voice/realtimeFacade.js", + "src/voice/realtimeInput.js", + "src/voice/realtimeInputPolicy.js", "src/voice/realtimePreferences.js", "src/voice/realtimeProtocol.js", - "src/voice/realtimeDiagnostics.js", - "src/voice/realtimeViewport.js", - "src/voice/realtimeInputPolicy.js", - "src/voice/realtimeInput.js", - "src/voice/realtimeCost.js", - "src/voice/realtimeFacade.js", "src/voice/realtimeRadio.js", + "src/voice/realtimeSession.js", "src/voice/realtimeTurns.js", - "src/voice/realtimeConnection.js" + "src/voice/realtimeViewport.js", + "src/voice/session.js", + "src/voice/sessionCommands.js", + "src/voice/voiceCost.js" ], "external": [] }, @@ -1309,27 +1682,16 @@ "./standalone/catalog" ], "modules": [ - "src/cyberSonarScene.js", - "src/cyberSonar.js", - "src/hudLayouts.js", - "src/hudLayoutPolicy.js", - "src/ui/radioPanelPlacement.js", - "src/cyberSonarGpu.js", - "src/scenes/nepalEvidencePack.js", - "src/data/bhoteKoshiEvent.js", - "src/ui/imagerySplit.js", - "src/data/bhoteKoshiEmbeddedMedia.js", - "src/data/bhoteKoshiLocator.js", - "src/data/bhoteKoshiIncidentPlaces.js", - "src/data/bhoteKoshiFloodPath.js", - "src/data/bhoteKoshiShotPaths.js", "src/annotations/annotationEngine.js", "src/annotations/annotationResolver.js", + "src/annotations/drawMode.js", + "src/annotations/drawTool.js", "src/annotations/hybridAnnotationRenderer.js", "src/annotations/index.js", "src/annotations/resolver.js", "src/annotations/screenAnnotationRenderer.js", "src/annotations/worldAnnotationRenderer.js", + "src/app/atmosphereCompat.js", "src/app/catalog.js", "src/app/constructCatalog.js", "src/app/controls.js", @@ -1340,28 +1702,32 @@ "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", "src/app/layers/cctv.js", + "src/app/layers/directions.js", "src/app/layers/earthquakes.js", "src/app/layers/firms.js", "src/app/layers/flights.js", + "src/app/layers/localAdsb.js", "src/app/layers/militaryAwareness.js", "src/app/layers/militaryFlights.js", "src/app/layers/militaryInstallations.js", "src/app/layers/overlayHost.js", + "src/app/layers/perimeters.js", "src/app/layers/radio.js", + "src/app/layers/recentImagery.js", "src/app/layers/rocketLaunches.js", "src/app/layers/satellites.js", "src/app/layers/submarineCables.js", "src/app/layers/traffic.js", + "src/app/layers/transit.js", + "src/app/localGeojsonServices.js", "src/app/operations.js", "src/app/scene.js", - "src/sources/sourceSlot.js", "src/app/sources.js", "src/app/startupChrome.js", "src/app/stateChannel.js", "src/app/surfaceServices.js", "src/app/tools.js", "src/app/viewer.js", - "src/app/atmosphereCompat.js", "src/bloom.js", "src/camera.js", "src/cameraGroundGuard.js", @@ -1377,6 +1743,10 @@ "src/contactsDetectionPolicy.js", "src/contextModePolicy.js", "src/creditKeyboard.js", + "src/cyberSonar.js", + "src/cyberSonarGpu.js", + "src/cyberSonarScene.js", + "src/data/adminBoundaries.js", "src/data/aircraftClass.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", @@ -1385,7 +1755,16 @@ "src/data/aisLiveVessels.js", "src/data/alprCameras.js", "src/data/analystEngine.js", + "src/data/bhoteKoshiEmbeddedMedia.js", + "src/data/bhoteKoshiEvent.js", + "src/data/bhoteKoshiFloodPath.js", + "src/data/bhoteKoshiIncidentPlaces.js", + "src/data/bhoteKoshiLocator.js", + "src/data/bhoteKoshiShotPaths.js", "src/data/bikeshare.js", + "src/data/bundledJson.js", + "src/data/cameraArrival.js", + "src/data/cameraSensitivity.js", "src/data/cctv.js", "src/data/cctvCards.js", "src/data/cctvFootprint.js", @@ -1395,28 +1774,19 @@ "src/data/cockpitAirLod.js", "src/data/cockpitContactDot.js", "src/data/contactMatch.js", + "src/data/contactPlayback.js", + "src/data/contactTrailRenderer.js", "src/data/contextStore.js", "src/data/dataCredits.js", - "src/data/wind.js", - "src/layers/wind/index.js", - "src/layers/wind/model.js", - "src/layers/wind/fields.js", - "src/layers/wind/streamlines.js", - "src/layers/wind/gpuRendering.js", - "src/layers/wind/relief.js", - "src/layers/wind/inspection.js", - "src/layers/wind/presentation.js", - "src/layers/wind/rendering.js", - "src/layers/weather/rasterTiles.js", - "src/layers/wind/source.js", - "src/sources/windBody.js", "src/data/detection.js", "src/data/detectionCohort.js", "src/data/detectionDraw.js", "src/data/detectionPolicy.js", "src/data/detectionPresentation.js", "src/data/detectionRenderDemand.js", + "src/data/directions.js", "src/data/earthquakes.js", + "src/data/feedState.js", "src/data/fireAnchors.js", "src/data/firmsAdapt.js", "src/data/firmsHeatmap.js", @@ -1427,11 +1797,15 @@ "src/data/geoid.js", "src/data/groundFloor.js", "src/data/groundSnap.js", + "src/data/gtfsRealtime.js", "src/data/iconOrientation.js", "src/data/infrastructure.js", + "src/data/infrastructureOverlayEntry.js", + "src/data/inputOwnership.js", "src/data/installationFeedback.js", "src/data/issPass.js", "src/data/labelArbiter.js", + "src/data/layerSnapshot.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/lifecycle.js", @@ -1439,9 +1813,13 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", "src/data/localLayers.js", + "src/data/local_data/natural_earth/countries.json", "src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/regions.json", + "src/data/local_data/natural_earth/states_provinces.json", "src/data/local_data/neighborhoods/san-francisco.json", + "src/data/local_data/osm_military_names/names.json", + "src/data/local_data/us_census_counties/counties.json", "src/data/manager.js", "src/data/meshFloorSampler.js", "src/data/militaryAwareness.js", @@ -1449,14 +1827,12 @@ "src/data/militaryFlights.js", "src/data/militaryInstallationData.js", "src/data/militaryInstallations.js", + "src/data/militaryNames.js", "src/data/militaryRegistry.js", + "src/data/modelEligibility.js", "src/data/modelVisualAnchor.js", "src/data/motionModel.js", "src/data/naturalEarthRegions.js", - "src/data/adminBoundaries.js", - "src/data/local_data/natural_earth/states_provinces.json", - "src/data/local_data/natural_earth/countries.json", - "src/data/local_data/us_census_counties/counties.json", "src/data/neighborhoodPolygons.js", "src/data/pickRegistry.js", "src/data/radio.js", @@ -1464,10 +1840,10 @@ "src/data/regionalBrief.js", "src/data/regionalModel.js", "src/data/renderAltitude.js", - "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/rocketLaunches.js", "src/data/routePlausible.js", + "src/data/routeSteps.js", "src/data/satelliteClass.js", "src/data/satellitePass.js", "src/data/satellites.js", @@ -1488,22 +1864,56 @@ "src/data/trafficPresetStyle.js", "src/data/trafficQueue.js", "src/data/trailRenderer.js", + "src/data/transit.js", + "src/data/transitFeeds.js", + "src/data/transitIcons.js", + "src/data/transitPresetStyle.js", + "src/data/transitProxy.js", "src/data/vesselLabels.js", + "src/data/wind.js", + "src/director/authoring.js", + "src/director/camera.js", + "src/director/cameraDocument.js", + "src/director/clock.js", + "src/director/document.js", + "src/director/documentFields.js", + "src/director/interactions/document.js", + "src/director/interactions/session.js", + "src/director/packs/geojson.js", + "src/director/packs/manifest.js", + "src/director/packs/session.js", + "src/director/packs/source.js", + "src/director/playback.js", + "src/director/sharing/bundle.js", + "src/director/sharing/lifetime.js", + "src/director/sharing/preview.js", + "src/director/timeline.js", "src/firstRunExperience.js", "src/hud.js", + "src/hudLayoutPolicy.js", + "src/hudLayouts.js", "src/hudLocality.js", "src/hudSummaryResponse.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/keySetup.js", "src/keySetupCore.mjs", "src/keylessGeocoder.js", "src/layers/aircraft/classification.js", + "src/layers/alpr/floors.js", "src/layers/alpr/index.js", "src/layers/alpr/model.js", + "src/layers/alpr/overlay.js", "src/layers/alpr/policy.js", "src/layers/alpr/presentation.js", - "src/layers/alpr/visuals.js", - "src/layers/alpr/overlay.js", + "src/layers/alpr/records.js", "src/layers/alpr/source.js", + "src/layers/alpr/tileRecords.js", + "src/layers/alpr/visuals.js", "src/layers/awareness/controls.js", "src/layers/awareness/dependencies.js", "src/layers/awareness/focus.js", @@ -1553,14 +1963,21 @@ "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", "src/layers/cctv/projection.js", - "src/layers/cctv/videoPlayback.js", "src/layers/cctv/rendering.js", "src/layers/cctv/selection.js", "src/layers/cctv/source.js", + "src/layers/cctv/sourcePolicy.js", "src/layers/cctv/state.js", "src/layers/cctv/testing.js", + "src/layers/cctv/videoPlayback.js", + "src/layers/cyclones/index.js", + "src/layers/cyclones/labels.js", + "src/layers/cyclones/rendering.js", + "src/layers/cyclones/source.js", + "src/layers/directions/index.js", "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", + "src/layers/earthquakes/records.js", "src/layers/earthquakes/source.js", "src/layers/firms/anchors.js", "src/layers/firms/cards.js", @@ -1581,10 +1998,14 @@ "src/layers/flights/index.js", "src/layers/flights/ingestion.js", "src/layers/flights/lifecycle.js", + "src/layers/flights/modelSpec.js", "src/layers/flights/motion.js", "src/layers/flights/policy.js", "src/layers/flights/queries.js", + "src/layers/flights/recordPolicy.js", + "src/layers/flights/records.js", "src/layers/flights/rendering.js", + "src/layers/flights/snapshotRenderer.js", "src/layers/flights/state.js", "src/layers/flights/testing.js", "src/layers/flights/tracking.js", @@ -1593,6 +2014,7 @@ "src/layers/installations/ingestion.js", "src/layers/installations/lifecycle.js", "src/layers/installations/model.js", + "src/layers/installations/namedMarkers.js", "src/layers/installations/policy.js", "src/layers/installations/rendering.js", "src/layers/installations/selection.js", @@ -1618,6 +2040,15 @@ "src/layers/launches/source.js", "src/layers/launches/state.js", "src/layers/launches/testing.js", + "src/layers/localAdsb/card.js", + "src/layers/localAdsb/enrichment.js", + "src/layers/localAdsb/feedNames.js", + "src/layers/localAdsb/feeds.js", + "src/layers/localAdsb/index.js", + "src/layers/localAdsb/models.js", + "src/layers/localAdsb/motion.js", + "src/layers/localAdsb/policy.js", + "src/layers/localAdsb/status.js", "src/layers/military/controller.js", "src/layers/military/index.js", "src/layers/military/ingestion.js", @@ -1625,10 +2056,18 @@ "src/layers/military/motion.js", "src/layers/military/policy.js", "src/layers/military/queries.js", + "src/layers/military/recordPolicy.js", + "src/layers/military/records.js", "src/layers/military/rendering.js", + "src/layers/military/snapshotRenderer.js", "src/layers/military/state.js", "src/layers/military/testing.js", "src/layers/military/tracking.js", + "src/layers/perimeters/cards.js", + "src/layers/perimeters/inciweb.js", + "src/layers/perimeters/index.js", + "src/layers/perimeters/records.js", + "src/layers/perimeters/source.js", "src/layers/radio/catalogModel.js", "src/layers/radio/categories.js", "src/layers/radio/clustering.js", @@ -1651,6 +2090,11 @@ "src/layers/radio/tuning.js", "src/layers/radio/tuningNoise.js", "src/layers/radio/volume.js", + "src/layers/recentImagery/catalog.js", + "src/layers/recentImagery/index.js", + "src/layers/recentImagery/model.js", + "src/layers/recentImagery/rendering.js", + "src/layers/recentImagery/thumbnails.js", "src/layers/satellites/catalog.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", @@ -1659,8 +2103,8 @@ "src/layers/satellites/labels.js", "src/layers/satellites/lifecycle.js", "src/layers/satellites/orbits.js", - "src/layers/satellites/records.js", "src/layers/satellites/policy.js", + "src/layers/satellites/records.js", "src/layers/satellites/rendering.js", "src/layers/satellites/source.js", "src/layers/satellites/state.js", @@ -1685,18 +2129,34 @@ "src/layers/traffic/footprint.js", "src/layers/traffic/index.js", "src/layers/traffic/ingestion.js", - "src/sources/requestDeadline.js", "src/layers/traffic/lifecycle.js", "src/layers/traffic/model.js", "src/layers/traffic/policy.js", "src/layers/traffic/rendering.js", "src/layers/traffic/retention.js", + "src/layers/traffic/roadModes.js", "src/layers/traffic/source.js", "src/layers/traffic/state.js", "src/layers/traffic/style.js", "src/layers/traffic/surface.js", "src/layers/traffic/timing.js", "src/layers/traffic/viewport.js", + "src/layers/transit/controls.js", + "src/layers/transit/height.js", + "src/layers/transit/index.js", + "src/layers/transit/ingestion.js", + "src/layers/transit/lifecycle.js", + "src/layers/transit/movement.js", + "src/layers/transit/policy.js", + "src/layers/transit/qaMetrics.js", + "src/layers/transit/queries.js", + "src/layers/transit/rendering.js", + "src/layers/transit/selection.js", + "src/layers/transit/source.js", + "src/layers/transit/state.js", + "src/layers/transit/testing.js", + "src/layers/transit/trails.js", + "src/layers/transit/viewport.js", "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", "src/layers/vessels/index.js", @@ -1704,11 +2164,34 @@ "src/layers/vessels/lifecycle.js", "src/layers/vessels/policy.js", "src/layers/vessels/queries.js", + "src/layers/vessels/recordPolicy.js", + "src/layers/vessels/records.js", "src/layers/vessels/rendering.js", "src/layers/vessels/selection.js", + "src/layers/vessels/snapshotRenderer.js", "src/layers/vessels/state.js", "src/layers/vessels/testing.js", "src/layers/vessels/tracking.js", + "src/layers/weather/clock.js", + "src/layers/weather/imageryHost.js", + "src/layers/weather/imageryOrder.js", + "src/layers/weather/index.js", + "src/layers/weather/infraredAlpha.js", + "src/layers/weather/infraredImage.js", + "src/layers/weather/rasterTiles.js", + "src/layers/weather/rendering.js", + "src/layers/weather/shellRendering.js", + "src/layers/weather/source.js", + "src/layers/wind/fields.js", + "src/layers/wind/gpuRendering.js", + "src/layers/wind/index.js", + "src/layers/wind/inspection.js", + "src/layers/wind/model.js", + "src/layers/wind/presentation.js", + "src/layers/wind/relief.js", + "src/layers/wind/rendering.js", + "src/layers/wind/source.js", + "src/layers/wind/streamlines.js", "src/loadingFeedback.js", "src/locationStatus.js", "src/locations.js", @@ -1719,15 +2202,17 @@ "src/maps/availability.js", "src/maps/catalog.js", "src/maps/controller.js", - "src/maps/imageryComparison.js", "src/maps/credits.js", "src/maps/defaultSources.js", "src/maps/google3d.js", "src/maps/imagery.js", + "src/maps/imageryComparison.js", + "src/maps/imageryHost.js", "src/maps/registry.js", "src/maps/terrain.js", "src/maps/terrainRetry.js", "src/navigationPolicy.js", + "src/nominatimGeocode.js", "src/orbit.js", "src/overlays/worldOverlay.js", "src/overlays/worldOverlayDraw.js", @@ -1735,17 +2220,36 @@ "src/panelStackLayout.js", "src/renderGovernor.js", "src/rightRailPolicy.js", + "src/scenes/cameraMotion.js", + "src/scenes/dataPacks/controller.js", + "src/scenes/dataPacks/geometry.js", + "src/scenes/dataPacks/presentation.js", "src/scenes/director.js", + "src/scenes/interactions.js", + "src/scenes/nepalEvidencePack.js", + "src/scenes/packs/defaults.js", + "src/scenes/packs/nepal.js", + "src/scenes/packs/registry.js", "src/scenes/playbackAdapter.js", - "src/director/playback.js", + "src/scenes/project.js", "src/scenes/recipes.js", "src/scenes/scenePolicy.js", + "src/scenes/sharing.js", + "src/scenes/shotPresentation.js", "src/scopeMask.js", + "src/sdr/controller.js", + "src/sdr/gain.js", + "src/sdr/spectrum.js", + "src/sdr/usbDevices.js", + "src/search/coordinateGeocoder.js", + "src/search/coordinateParser.js", "src/search/defaults.js", "src/search/geospatial.js", "src/search/google.js", "src/search/http.js", + "src/search/nominatim.js", "src/search/placeSearch.js", + "src/search/presetGeocoder.js", "src/services/application.js", "src/services/groundFloor.js", "src/services/groundSnap.js", @@ -1753,10 +2257,28 @@ "src/services/requests.js", "src/services/terrainHeights.js", "src/sharelink.js", + "src/sources/adsbRecords.js", + "src/sources/capability.js", + "src/sources/featureGeometry.js", + "src/sources/featureSource.js", + "src/sources/httpBody.js", "src/sources/live/aircraft.js", "src/sources/live/contract.js", "src/sources/live/standalone.js", "src/sources/live/vessels.js", + "src/sources/militaryTileGeometry.js", + "src/sources/nominatim.js", + "src/sources/openFreeMap.js", + "src/sources/overpassFeatures.js", + "src/sources/overpassFeaturesRecords.js", + "src/sources/overpassRoads.js", + "src/sources/phaseTiming.js", + "src/sources/reference.js", + "src/sources/requestDeadline.js", + "src/sources/sourceSlot.js", + "src/sources/transitHistory.js", + "src/sources/vectorTiles.js", + "src/sources/windBody.js", "src/splitFlap.js", "src/standalone/catalog.js", "src/standalone/layerSources.js", @@ -1766,27 +2288,26 @@ "src/styles/snow.js", "src/styles/surveillance.js", "src/styles/thermal.js", + "src/ui/aircraftDisplay.js", "src/ui/applicationShell.js", - "src/ui/cyberSonarControls.js", - "src/ui/locationNavigation.js", - "src/ui/cockpitCoordinator.js", - "src/ui/cameraOrientationControls.js", "src/ui/applicationShortcuts.js", + "src/ui/cameraOrientationControls.js", "src/ui/cctv.js", "src/ui/cctvBindings.js", "src/ui/cctvCalibration.js", "src/ui/cctvControls.js", "src/ui/cctvFrames.js", - "src/ui/cctvVideo.js", "src/ui/cctvPresentation.js", + "src/ui/cctvVideo.js", + "src/ui/chipGroup.js", "src/ui/clearLayersControl.js", "src/ui/cockpit.js", "src/ui/cockpitBriefing.js", "src/ui/cockpitCamera.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", + "src/ui/cockpitCoordinator.js", "src/ui/cockpitDisplayPortal.js", - "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", "src/ui/cockpitInstruments.js", "src/ui/cockpitLayout.js", @@ -1803,20 +2324,23 @@ "src/ui/contextSession.js", "src/ui/contextSubscriptions.js", "src/ui/contextTransactions.js", + "src/ui/cyberSonarControls.js", + "src/ui/displayBindings.js", "src/ui/displayControls.js", + "src/ui/displayPanelScroll.js", "src/ui/effects.js", "src/ui/frameRateMonitor.js", + "src/ui/imageryBoxTool.js", + "src/ui/imagerySplit.js", + "src/ui/layerBindings.js", "src/ui/layerPanel.js", - "src/ui/weatherPanel.js", - "src/ui/railCards.js", - "src/ui/railCardBlocks.js", - "src/ui/chipGroup.js", - "src/ui/rowList.js", - "src/ui/railTimeline.js", "src/ui/layers.js", "src/ui/leftPanelRail.js", + "src/ui/localSdrControls.js", + "src/ui/localSdrPresentation.js", "src/ui/location.js", "src/ui/locationControls.js", + "src/ui/locationNavigation.js", "src/ui/locationSearch.js", "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -1831,15 +2355,23 @@ "src/ui/radio.js", "src/ui/radioBindings.js", "src/ui/radioControls.js", + "src/ui/radioPanelPlacement.js", "src/ui/radioPresentation.js", "src/ui/radioTunerModel.js", + "src/ui/railCardBlocks.js", + "src/ui/railCards.js", + "src/ui/railTimeline.js", + "src/ui/recentImagery.js", "src/ui/recordingControls.js", "src/ui/rightPanelRail.js", + "src/ui/rowList.js", "src/ui/sceneControls.js", "src/ui/scenePresentation.js", + "src/ui/sceneSharing.js", "src/ui/scenes.js", "src/ui/shareRestoration.js", "src/ui/shellElements.js", + "src/ui/shellFacade.js", "src/ui/shellFeedback.js", "src/ui/styleParameters.js", "src/ui/surfaceKeyboard.js", @@ -1848,186 +2380,32 @@ "src/ui/visualInput.js", "src/ui/visualPresets.js", "src/ui/visualSettings.js", + "src/ui/weatherPanel.js", "src/voice/commands.js", "src/voice/control.js", "src/voice/gevActions.js", "src/voice/gevRealtime.js", "src/voice/layerSummary.js", "src/voice/realtimeBackend.js", + "src/voice/realtimeConnection.js", "src/voice/realtimeController.js", - "src/voice/voiceCost.js", - "src/weatherEffectsMath.js", - "src/worldFocus.js", - "src/layers/flights/records.js", - "src/layers/flights/recordPolicy.js", - "src/layers/flights/snapshotRenderer.js", - "src/layers/flights/modelSpec.js", - "src/layers/military/records.js", - "src/layers/military/recordPolicy.js", - "src/layers/military/snapshotRenderer.js", - "src/layers/vessels/records.js", - "src/layers/vessels/recordPolicy.js", - "src/layers/vessels/snapshotRenderer.js", - "src/data/inputOwnership.js", - "src/annotations/drawMode.js", - "src/annotations/drawTool.js", - "src/data/routeSteps.js", - "src/layers/directions/index.js", - "src/data/directions.js", - "src/app/layers/directions.js", - "src/search/coordinateGeocoder.js", - "src/search/coordinateParser.js", - "src/search/presetGeocoder.js", - "src/voice/session.js", - "src/voice/sessionCommands.js", - "src/voice/realtimeSession.js", + "src/voice/realtimeCost.js", + "src/voice/realtimeDiagnostics.js", "src/voice/realtimeEvents.js", - "src/app/localGeojsonServices.js", - "src/data/feedState.js", - "src/data/layerSnapshot.js", - "src/layers/alpr/records.js", - "src/layers/earthquakes/records.js", - "src/app/layers/perimeters.js", - "src/layers/perimeters/index.js", - "src/layers/perimeters/source.js", - "src/layers/perimeters/records.js", - "src/layers/perimeters/cards.js", - "src/layers/perimeters/inciweb.js", - "src/layers/cctv/sourcePolicy.js", - "src/sources/reference.js", - "src/search/nominatim.js", - "src/nominatimGeocode.js", - "src/sources/httpBody.js", - "src/sources/nominatim.js", - "src/app/layers/transit.js", - "src/data/transit.js", - "src/data/transitFeeds.js", - "src/data/transitIcons.js", - "src/data/transitPresetStyle.js", - "src/layers/transit/controls.js", - "src/layers/transit/height.js", - "src/layers/transit/index.js", - "src/layers/transit/ingestion.js", - "src/layers/transit/lifecycle.js", - "src/layers/transit/policy.js", - "src/layers/transit/queries.js", - "src/layers/transit/rendering.js", - "src/layers/transit/selection.js", - "src/layers/transit/state.js", - "src/layers/transit/testing.js", - "src/layers/transit/viewport.js", - "src/data/cameraSensitivity.js", - "src/data/contactPlayback.js", - "src/data/contactTrailRenderer.js", - "src/data/transitProxy.js", - "src/data/gtfsRealtime.js", - "src/data/modelEligibility.js", - "src/layers/transit/trails.js", - "src/layers/transit/movement.js", - "src/layers/transit/qaMetrics.js", - "src/layers/transit/source.js", - "src/sources/transitHistory.js", - "src/sources/featureGeometry.js", - "src/sources/overpassFeaturesRecords.js", - "src/sources/overpassFeatures.js", - "src/sources/featureSource.js", - "src/sources/overpassRoads.js", - "src/ui/aircraftDisplay.js", - "src/ui/layerBindings.js", - "src/ui/displayBindings.js", - "src/ui/shellFacade.js", + "src/voice/realtimeFacade.js", + "src/voice/realtimeInput.js", + "src/voice/realtimeInputPolicy.js", "src/voice/realtimePreferences.js", "src/voice/realtimeProtocol.js", - "src/voice/realtimeDiagnostics.js", - "src/voice/realtimeViewport.js", - "src/voice/realtimeInputPolicy.js", - "src/voice/realtimeInput.js", - "src/voice/realtimeCost.js", - "src/voice/realtimeFacade.js", "src/voice/realtimeRadio.js", + "src/voice/realtimeSession.js", "src/voice/realtimeTurns.js", - "src/voice/realtimeConnection.js", - "src/layers/weather/index.js", - "src/layers/weather/clock.js", - "src/layers/weather/rendering.js", - "src/layers/weather/shellRendering.js", - "src/layers/weather/infraredAlpha.js", - "src/layers/weather/infraredImage.js", - "src/layers/weather/source.js", - "src/director/clock.js", - "src/director/timeline.js", - "src/scenes/shotPresentation.js", - "src/scenes/packs/registry.js", - "src/scenes/packs/defaults.js", - "src/scenes/packs/nepal.js", - "src/director/document.js", - "src/scenes/project.js", - "src/director/documentFields.js", - "src/director/cameraDocument.js", - "src/director/camera.js", - "src/scenes/cameraMotion.js", - "src/director/packs/source.js", - "src/director/packs/session.js", - "src/director/packs/geojson.js", - "src/director/packs/manifest.js", - "src/scenes/dataPacks/controller.js", - "src/scenes/dataPacks/presentation.js", - "src/scenes/dataPacks/geometry.js", - "src/director/interactions/document.js", - "src/director/interactions/session.js", - "src/scenes/interactions.js", - "src/director/sharing/bundle.js", - "src/director/sharing/preview.js", - "src/director/sharing/lifetime.js", - "src/director/authoring.js", - "src/scenes/sharing.js", - "src/ui/sceneSharing.js", - "src/layers/weather/imageryOrder.js", - "src/layers/weather/imageryHost.js", - "src/layers/cyclones/index.js", - "src/layers/cyclones/rendering.js", - "src/layers/cyclones/labels.js", - "src/layers/cyclones/source.js", - "src/app/layers/localAdsb.js", - "src/layers/localAdsb/index.js", - "src/layers/localAdsb/policy.js", - "src/layers/localAdsb/card.js", - "src/layers/localAdsb/feeds.js", - "src/layers/localAdsb/status.js", - "src/layers/localAdsb/feedNames.js", - "src/layers/localAdsb/motion.js", - "src/layers/localAdsb/models.js", - "src/layers/localAdsb/enrichment.js", - "src/sources/adsbRecords.js", - "src/sdr/controller.js", - "src/sdr/spectrum.js", - "src/sdr/gain.js", - "src/sdr/usbDevices.js", - "src/ui/localSdrControls.js", - "src/ui/localSdrPresentation.js", - "src/app/layers/recentImagery.js", - "src/layers/recentImagery/index.js", - "src/layers/recentImagery/model.js", - "src/layers/recentImagery/catalog.js", - "src/layers/recentImagery/rendering.js", - "src/layers/recentImagery/thumbnails.js", - "src/maps/imageryHost.js", - "src/ui/recentImagery.js", - "src/ui/imageryBoxTool.js", - "src/layers/alpr/tileRecords.js", - "src/sources/capability.js", - "src/sources/openFreeMap.js", - "src/sources/militaryTileGeometry.js", - "src/sources/vectorTiles.js", - "src/sources/phaseTiming.js", - "src/layers/traffic/roadModes.js", - "src/data/cameraArrival.js", - "src/layers/alpr/floors.js", - "src/data/militaryNames.js", - "src/sources/requestDeadline.js", - "src/data/local_data/osm_military_names/names.json", - "src/layers/installations/namedMarkers.js", - "src/data/infrastructureOverlayEntry.js" + "src/voice/realtimeViewport.js", + "src/voice/session.js", + "src/voice/sessionCommands.js", + "src/voice/voiceCost.js", + "src/weatherEffectsMath.js", + "src/worldFocus.js" ], "external": [ "hls.js", @@ -2041,61 +2419,67 @@ ] }, "application-catalog": { - "exports": ["./application/catalog"], - "modules": ["src/app/catalog.js"], + "exports": [ + "./application/catalog" + ], + "modules": [ + "src/app/catalog.js" + ], "external": [] }, "application-layer-construction": { - "exports": ["./application/layers"], + "exports": [ + "./application/layers" + ], "modules": [ - "src/cyberSonar.js", - "src/data/bhoteKoshiEvent.js", - "src/ui/imagerySplit.js", - "src/maps/imageryComparison.js", - "src/data/bhoteKoshiEmbeddedMedia.js", - "src/data/bhoteKoshiLocator.js", - "src/data/bhoteKoshiIncidentPlaces.js", - "src/data/bhoteKoshiFloodPath.js", - "src/data/bhoteKoshiShotPaths.js", "src/annotations/annotationResolver.js", "src/annotations/resolver.js", + "src/annotations/worldAnnotationRenderer.js", "src/app/catalog.js", "src/app/constructCatalog.js", - "src/layers/wind/index.js", - "src/layers/wind/model.js", - "src/layers/wind/fields.js", - "src/layers/wind/streamlines.js", - "src/layers/wind/gpuRendering.js", - "src/layers/wind/relief.js", - "src/layers/wind/inspection.js", - "src/layers/wind/presentation.js", - "src/layers/wind/rendering.js", - "src/layers/weather/rasterTiles.js", "src/app/layers/aisLiveVessels.js", "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", "src/app/layers/cctv.js", + "src/app/layers/directions.js", "src/app/layers/earthquakes.js", "src/app/layers/firms.js", "src/app/layers/flights.js", + "src/app/layers/localAdsb.js", "src/app/layers/militaryAwareness.js", "src/app/layers/militaryFlights.js", "src/app/layers/militaryInstallations.js", "src/app/layers/overlayHost.js", + "src/app/layers/perimeters.js", "src/app/layers/radio.js", + "src/app/layers/recentImagery.js", "src/app/layers/rocketLaunches.js", "src/app/layers/satellites.js", "src/app/layers/submarineCables.js", "src/app/layers/traffic.js", - "src/sources/sourceSlot.js", + "src/app/layers/transit.js", + "src/app/localGeojsonServices.js", + "src/cameraGroundGuard.js", + "src/cameraVerbs.js", "src/cctvFocusRequest.js", "src/celestialRing.js", "src/cockpitMath.js", + "src/cyberSonar.js", + "src/data/adminBoundaries.js", "src/data/aircraftClass.js", "src/data/aircraftIcons.js", "src/data/aircraftMeta.js", "src/data/aircraftNearbyPolicy.js", "src/data/aircraftRecession.js", + "src/data/bhoteKoshiEmbeddedMedia.js", + "src/data/bhoteKoshiEvent.js", + "src/data/bhoteKoshiFloodPath.js", + "src/data/bhoteKoshiIncidentPlaces.js", + "src/data/bhoteKoshiLocator.js", + "src/data/bhoteKoshiShotPaths.js", + "src/data/bundledJson.js", + "src/data/cameraArrival.js", + "src/data/cameraSensitivity.js", "src/data/cctvCards.js", "src/data/cctvFootprint.js", "src/data/cctvGizmo.js", @@ -2104,49 +2488,63 @@ "src/data/cockpitAirLod.js", "src/data/cockpitContactDot.js", "src/data/contactMatch.js", + "src/data/contactPlayback.js", + "src/data/contactTrailRenderer.js", "src/data/contextStore.js", "src/data/dataCredits.js", + "src/data/detection.js", "src/data/detectionCohort.js", "src/data/detectionDraw.js", "src/data/detectionPolicy.js", + "src/data/detectionPresentation.js", + "src/data/detectionRenderDemand.js", + "src/data/directions.js", "src/data/firmsAdapt.js", "src/data/firmsLabels.js", "src/data/flowMatch.js", "src/data/focusDeemphasis.js", "src/data/geoid.js", + "src/data/groundFloor.js", + "src/data/gtfsRealtime.js", "src/data/iconOrientation.js", "src/data/infrastructure.js", + "src/data/infrastructureOverlayEntry.js", + "src/data/inputOwnership.js", "src/data/installationFeedback.js", "src/data/issPass.js", - "src/data/satellitePass.js", "src/data/labelArbiter.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", "src/data/localGeojson.js", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", + "src/data/local_data/natural_earth/countries.json", "src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/regions.json", + "src/data/local_data/natural_earth/states_provinces.json", "src/data/local_data/neighborhoods/san-francisco.json", + "src/data/local_data/osm_military_names/names.json", + "src/data/local_data/us_census_counties/counties.json", "src/data/militaryAwarenessEngine.js", "src/data/militaryInstallationData.js", + "src/data/militaryNames.js", + "src/data/modelEligibility.js", "src/data/modelVisualAnchor.js", "src/data/motionModel.js", "src/data/naturalEarthRegions.js", - "src/data/adminBoundaries.js", - "src/data/local_data/natural_earth/states_provinces.json", - "src/data/local_data/natural_earth/countries.json", - "src/data/local_data/us_census_counties/counties.json", "src/data/neighborhoodPolygons.js", "src/data/pickRegistry.js", "src/data/radioCountry.js", + "src/data/regionalModel.js", "src/data/renderAltitude.js", - "src/data/bundledJson.js", "src/data/retryableLoad.js", "src/data/routePlausible.js", + "src/data/routeSteps.js", "src/data/satelliteClass.js", + "src/data/satellitePass.js", "src/data/scenePick.js", "src/data/spriteOrder.js", + "src/data/terrainHeights.js", "src/data/tomtomTiles.js", "src/data/tr3bRegistry.js", "src/data/trackedCamera.js", @@ -2158,16 +2556,29 @@ "src/data/trafficPresetStyle.js", "src/data/trafficQueue.js", "src/data/trailRenderer.js", + "src/data/transitFeeds.js", + "src/data/transitIcons.js", + "src/data/transitPresetStyle.js", + "src/data/transitProxy.js", "src/data/vesselLabels.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/keylessGeocoder.js", "src/layers/aircraft/classification.js", + "src/layers/alpr/floors.js", "src/layers/alpr/index.js", "src/layers/alpr/model.js", + "src/layers/alpr/overlay.js", "src/layers/alpr/policy.js", "src/layers/alpr/presentation.js", - "src/layers/alpr/visuals.js", - "src/layers/alpr/overlay.js", + "src/layers/alpr/records.js", "src/layers/alpr/source.js", + "src/layers/alpr/tileRecords.js", + "src/layers/alpr/visuals.js", "src/layers/awareness/controls.js", "src/layers/awareness/dependencies.js", "src/layers/awareness/focus.js", @@ -2217,14 +2628,21 @@ "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", "src/layers/cctv/projection.js", - "src/layers/cctv/videoPlayback.js", "src/layers/cctv/rendering.js", "src/layers/cctv/selection.js", "src/layers/cctv/source.js", + "src/layers/cctv/sourcePolicy.js", "src/layers/cctv/state.js", "src/layers/cctv/testing.js", + "src/layers/cctv/videoPlayback.js", + "src/layers/cyclones/index.js", + "src/layers/cyclones/labels.js", + "src/layers/cyclones/rendering.js", + "src/layers/cyclones/source.js", + "src/layers/directions/index.js", "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", + "src/layers/earthquakes/records.js", "src/layers/earthquakes/source.js", "src/layers/firms/anchors.js", "src/layers/firms/cards.js", @@ -2245,10 +2663,14 @@ "src/layers/flights/index.js", "src/layers/flights/ingestion.js", "src/layers/flights/lifecycle.js", + "src/layers/flights/modelSpec.js", "src/layers/flights/motion.js", "src/layers/flights/policy.js", "src/layers/flights/queries.js", + "src/layers/flights/recordPolicy.js", + "src/layers/flights/records.js", "src/layers/flights/rendering.js", + "src/layers/flights/snapshotRenderer.js", "src/layers/flights/state.js", "src/layers/flights/testing.js", "src/layers/flights/tracking.js", @@ -2257,6 +2679,7 @@ "src/layers/installations/ingestion.js", "src/layers/installations/lifecycle.js", "src/layers/installations/model.js", + "src/layers/installations/namedMarkers.js", "src/layers/installations/policy.js", "src/layers/installations/rendering.js", "src/layers/installations/selection.js", @@ -2282,6 +2705,15 @@ "src/layers/launches/source.js", "src/layers/launches/state.js", "src/layers/launches/testing.js", + "src/layers/localAdsb/card.js", + "src/layers/localAdsb/enrichment.js", + "src/layers/localAdsb/feedNames.js", + "src/layers/localAdsb/feeds.js", + "src/layers/localAdsb/index.js", + "src/layers/localAdsb/models.js", + "src/layers/localAdsb/motion.js", + "src/layers/localAdsb/policy.js", + "src/layers/localAdsb/status.js", "src/layers/military/controller.js", "src/layers/military/index.js", "src/layers/military/ingestion.js", @@ -2289,10 +2721,18 @@ "src/layers/military/motion.js", "src/layers/military/policy.js", "src/layers/military/queries.js", + "src/layers/military/recordPolicy.js", + "src/layers/military/records.js", "src/layers/military/rendering.js", + "src/layers/military/snapshotRenderer.js", "src/layers/military/state.js", "src/layers/military/testing.js", "src/layers/military/tracking.js", + "src/layers/perimeters/cards.js", + "src/layers/perimeters/inciweb.js", + "src/layers/perimeters/index.js", + "src/layers/perimeters/records.js", + "src/layers/perimeters/source.js", "src/layers/radio/catalogModel.js", "src/layers/radio/categories.js", "src/layers/radio/clustering.js", @@ -2315,6 +2755,11 @@ "src/layers/radio/tuning.js", "src/layers/radio/tuningNoise.js", "src/layers/radio/volume.js", + "src/layers/recentImagery/catalog.js", + "src/layers/recentImagery/index.js", + "src/layers/recentImagery/model.js", + "src/layers/recentImagery/rendering.js", + "src/layers/recentImagery/thumbnails.js", "src/layers/satellites/catalog.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", @@ -2323,8 +2768,8 @@ "src/layers/satellites/labels.js", "src/layers/satellites/lifecycle.js", "src/layers/satellites/orbits.js", - "src/layers/satellites/records.js", "src/layers/satellites/policy.js", + "src/layers/satellites/records.js", "src/layers/satellites/rendering.js", "src/layers/satellites/source.js", "src/layers/satellites/state.js", @@ -2348,18 +2793,34 @@ "src/layers/traffic/footprint.js", "src/layers/traffic/index.js", "src/layers/traffic/ingestion.js", - "src/sources/requestDeadline.js", "src/layers/traffic/lifecycle.js", "src/layers/traffic/model.js", "src/layers/traffic/policy.js", "src/layers/traffic/rendering.js", "src/layers/traffic/retention.js", + "src/layers/traffic/roadModes.js", "src/layers/traffic/source.js", "src/layers/traffic/state.js", "src/layers/traffic/style.js", "src/layers/traffic/surface.js", "src/layers/traffic/timing.js", "src/layers/traffic/viewport.js", + "src/layers/transit/controls.js", + "src/layers/transit/height.js", + "src/layers/transit/index.js", + "src/layers/transit/ingestion.js", + "src/layers/transit/lifecycle.js", + "src/layers/transit/movement.js", + "src/layers/transit/policy.js", + "src/layers/transit/qaMetrics.js", + "src/layers/transit/queries.js", + "src/layers/transit/rendering.js", + "src/layers/transit/selection.js", + "src/layers/transit/source.js", + "src/layers/transit/state.js", + "src/layers/transit/testing.js", + "src/layers/transit/trails.js", + "src/layers/transit/viewport.js", "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", "src/layers/vessels/index.js", @@ -2367,151 +2828,79 @@ "src/layers/vessels/lifecycle.js", "src/layers/vessels/policy.js", "src/layers/vessels/queries.js", + "src/layers/vessels/recordPolicy.js", + "src/layers/vessels/records.js", "src/layers/vessels/rendering.js", "src/layers/vessels/selection.js", + "src/layers/vessels/snapshotRenderer.js", "src/layers/vessels/state.js", "src/layers/vessels/testing.js", "src/layers/vessels/tracking.js", + "src/layers/weather/clock.js", + "src/layers/weather/imageryHost.js", + "src/layers/weather/imageryOrder.js", + "src/layers/weather/index.js", + "src/layers/weather/infraredAlpha.js", + "src/layers/weather/infraredImage.js", + "src/layers/weather/rasterTiles.js", + "src/layers/weather/rendering.js", + "src/layers/weather/shellRendering.js", + "src/layers/weather/source.js", + "src/layers/wind/fields.js", + "src/layers/wind/gpuRendering.js", + "src/layers/wind/index.js", + "src/layers/wind/inspection.js", + "src/layers/wind/model.js", + "src/layers/wind/presentation.js", + "src/layers/wind/relief.js", + "src/layers/wind/rendering.js", + "src/layers/wind/streamlines.js", "src/locations.js", + "src/maps/imageryComparison.js", + "src/maps/imageryHost.js", "src/navigationPolicy.js", + "src/nominatimGeocode.js", "src/overlays/worldOverlay.js", "src/overlays/worldOverlayDraw.js", "src/overlays/worldOverlayTokens.js", "src/renderGovernor.js", + "src/sdr/controller.js", + "src/sdr/gain.js", + "src/sdr/spectrum.js", + "src/sdr/usbDevices.js", + "src/search/coordinateGeocoder.js", + "src/search/coordinateParser.js", "src/search/defaults.js", "src/search/geospatial.js", "src/search/google.js", "src/search/http.js", + "src/search/nominatim.js", "src/search/placeSearch.js", + "src/search/presetGeocoder.js", "src/services/application.js", + "src/services/groundFloor.js", "src/services/requests.js", + "src/services/terrainHeights.js", + "src/sources/adsbRecords.js", + "src/sources/capability.js", + "src/sources/featureGeometry.js", + "src/sources/featureSource.js", + "src/sources/httpBody.js", + "src/sources/militaryTileGeometry.js", + "src/sources/nominatim.js", + "src/sources/openFreeMap.js", + "src/sources/overpassFeatures.js", + "src/sources/overpassFeaturesRecords.js", + "src/sources/overpassRoads.js", + "src/sources/phaseTiming.js", + "src/sources/requestDeadline.js", + "src/sources/sourceSlot.js", + "src/sources/transitHistory.js", + "src/sources/vectorTiles.js", + "src/ui/imagerySplit.js", "src/ui/panelRailGeometry.js", "src/ui/radioTunerModel.js", - "src/worldFocus.js", - "src/layers/flights/records.js", - "src/layers/flights/recordPolicy.js", - "src/layers/flights/snapshotRenderer.js", - "src/layers/flights/modelSpec.js", - "src/layers/military/records.js", - "src/layers/military/recordPolicy.js", - "src/layers/military/snapshotRenderer.js", - "src/layers/vessels/records.js", - "src/layers/vessels/recordPolicy.js", - "src/layers/vessels/snapshotRenderer.js", - "src/data/inputOwnership.js", - "src/annotations/worldAnnotationRenderer.js", - "src/cameraGroundGuard.js", - "src/cameraVerbs.js", - "src/services/groundFloor.js", - "src/data/routeSteps.js", - "src/layers/directions/index.js", - "src/services/terrainHeights.js", - "src/data/terrainHeights.js", - "src/data/groundFloor.js", - "src/data/directions.js", - "src/app/layers/directions.js", - "src/search/coordinateGeocoder.js", - "src/search/coordinateParser.js", - "src/search/presetGeocoder.js", - "src/app/localGeojsonServices.js", - "src/layers/alpr/records.js", - "src/layers/earthquakes/records.js", - "src/app/layers/perimeters.js", - "src/layers/perimeters/index.js", - "src/layers/perimeters/source.js", - "src/layers/perimeters/records.js", - "src/layers/perimeters/cards.js", - "src/layers/perimeters/inciweb.js", - "src/layers/cctv/sourcePolicy.js", - "src/search/nominatim.js", - "src/nominatimGeocode.js", - "src/data/regionalModel.js", - "src/sources/httpBody.js", - "src/sources/nominatim.js", - "src/app/layers/transit.js", - "src/data/transitFeeds.js", - "src/data/transitIcons.js", - "src/data/transitPresetStyle.js", - "src/layers/transit/controls.js", - "src/layers/transit/height.js", - "src/layers/transit/index.js", - "src/layers/transit/ingestion.js", - "src/layers/transit/lifecycle.js", - "src/layers/transit/policy.js", - "src/layers/transit/queries.js", - "src/layers/transit/rendering.js", - "src/layers/transit/selection.js", - "src/layers/transit/state.js", - "src/layers/transit/testing.js", - "src/layers/transit/viewport.js", - "src/data/cameraSensitivity.js", - "src/data/contactPlayback.js", - "src/data/contactTrailRenderer.js", - "src/data/transitProxy.js", - "src/data/gtfsRealtime.js", - "src/layers/transit/trails.js", - "src/layers/transit/movement.js", - "src/layers/transit/qaMetrics.js", - "src/layers/transit/source.js", - "src/sources/transitHistory.js", - "src/sources/featureGeometry.js", - "src/sources/overpassFeaturesRecords.js", - "src/sources/overpassFeatures.js", - "src/sources/featureSource.js", - "src/sources/overpassRoads.js", - "src/layers/weather/index.js", - "src/layers/weather/clock.js", - "src/layers/weather/rendering.js", - "src/layers/weather/shellRendering.js", - "src/layers/weather/infraredAlpha.js", - "src/layers/weather/infraredImage.js", - "src/layers/weather/source.js", - "src/layers/weather/imageryOrder.js", - "src/layers/weather/imageryHost.js", - "src/layers/cyclones/index.js", - "src/layers/cyclones/rendering.js", - "src/layers/cyclones/labels.js", - "src/layers/cyclones/source.js", - "src/app/layers/localAdsb.js", - "src/layers/localAdsb/index.js", - "src/layers/localAdsb/policy.js", - "src/layers/localAdsb/card.js", - "src/layers/localAdsb/feeds.js", - "src/layers/localAdsb/status.js", - "src/layers/localAdsb/feedNames.js", - "src/layers/localAdsb/motion.js", - "src/layers/localAdsb/models.js", - "src/layers/localAdsb/enrichment.js", - "src/sources/adsbRecords.js", - "src/sdr/controller.js", - "src/sdr/spectrum.js", - "src/sdr/gain.js", - "src/sdr/usbDevices.js", - "src/data/detection.js", - "src/data/detectionRenderDemand.js", - "src/data/detectionPresentation.js", - "src/data/modelEligibility.js", - "src/app/layers/recentImagery.js", - "src/layers/recentImagery/index.js", - "src/layers/recentImagery/model.js", - "src/layers/recentImagery/catalog.js", - "src/layers/recentImagery/rendering.js", - "src/layers/recentImagery/thumbnails.js", - "src/maps/imageryHost.js", - "src/layers/alpr/tileRecords.js", - "src/sources/capability.js", - "src/sources/openFreeMap.js", - "src/sources/militaryTileGeometry.js", - "src/sources/vectorTiles.js", - "src/sources/phaseTiming.js", - "src/layers/traffic/roadModes.js", - "src/data/cameraArrival.js", - "src/layers/alpr/floors.js", - "src/data/militaryNames.js", - "src/sources/requestDeadline.js", - "src/data/local_data/osm_military_names/names.json", - "src/layers/installations/namedMarkers.js", - "src/data/infrastructureOverlayEntry.js" + "src/worldFocus.js" ], "external": [ "hls.js", @@ -2525,7 +2914,9 @@ ] }, "application-operations": { - "exports": ["./application/operations"], + "exports": [ + "./application/operations" + ], "modules": [ "src/annotations/annotationResolver.js", "src/annotations/resolver.js", @@ -2586,26 +2977,43 @@ ] }, "layer-lifecycle": { - "exports": ["./data/lifecycle"], - "modules": ["src/data/lifecycle.js", "src/data/cameraArrival.js"], + "exports": [ + "./data/lifecycle" + ], + "modules": [ + "src/data/lifecycle.js", + "src/data/cameraArrival.js" + ], "external": [] }, "ui-navigation": { - "exports": ["./ui/navigation"], + "exports": [ + "./ui/navigation" + ], "modules": [ "src/navigationPolicy.js", "src/ui/navigationController.js", "src/app/stateChannel.js", "src/data/cameraArrival.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-share-restoration": { - "exports": ["./ui/share-restoration"], + "exports": [ + "./ui/share-restoration" + ], "modules": [ "src/data/installationFeedback.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/loadingFeedback.js", "src/navigationPolicy.js", "src/ui/shareRestoration.js", @@ -2614,16 +3022,23 @@ "external": [] }, "ui-visual-settings": { - "exports": ["./ui/visual-settings"], + "exports": [ + "./ui/visual-settings" + ], "modules": [ - "src/cyberSonar.js", - "src/hudLayouts.js", - "src/hudLayoutPolicy.js", - "src/ui/radioPanelPlacement.js", "src/bloom.js", "src/cockpitVisionPolicy.js", "src/contactsDetectionPolicy.js", + "src/cyberSonar.js", "src/data/detectionPolicy.js", + "src/hudLayoutPolicy.js", + "src/hudLayouts.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/styles/anime.js", "src/styles/noir.js", "src/styles/retro.js", @@ -2632,6 +3047,7 @@ "src/styles/thermal.js", "src/ui/applicationShortcuts.js", "src/ui/effects.js", + "src/ui/radioPanelPlacement.js", "src/ui/styleParameters.js", "src/ui/uiLifetime.js", "src/ui/visualEffects.js", @@ -2639,14 +3055,23 @@ "src/ui/visualPresets.js", "src/ui/visualSettings.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-panel-chrome": { - "exports": ["./ui/panel-chrome"], + "exports": [ + "./ui/panel-chrome" + ], "modules": [ - "src/ui/displayPanelScroll.js", - "src/ui/radioPanelPlacement.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", "src/panelStackLayout.js", + "src/ui/displayPanelScroll.js", "src/ui/leftPanelRail.js", "src/ui/panelChrome.js", "src/ui/panelDisclosure.js", @@ -2655,13 +3080,16 @@ "src/ui/panelPositionControls.js", "src/ui/panelRailGeometry.js", "src/ui/panelRails.js", + "src/ui/radioPanelPlacement.js", "src/ui/rightPanelRail.js", "src/ui/uiLifetime.js" ], "external": [] }, "civil-flight-records": { - "exports": ["./layers/flights/records"], + "exports": [ + "./layers/flights/records" + ], "modules": [ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", @@ -2672,7 +3100,9 @@ "external": [] }, "civil-flight-ingestion": { - "exports": ["./layers/flights/ingestion"], + "exports": [ + "./layers/flights/ingestion" + ], "modules": [ "src/layers/flights/ingestion.js", "src/layers/flights/recordPolicy.js" @@ -2680,7 +3110,9 @@ "external": [] }, "military-flight-records": { - "exports": ["./layers/military/records"], + "exports": [ + "./layers/military/records" + ], "modules": [ "src/layers/military/records.js", "src/layers/military/recordPolicy.js", @@ -2691,7 +3123,9 @@ "external": [] }, "military-flight-ingestion": { - "exports": ["./layers/military/ingestion"], + "exports": [ + "./layers/military/ingestion" + ], "modules": [ "src/layers/military/ingestion.js", "src/layers/military/recordPolicy.js" @@ -2699,7 +3133,9 @@ "external": [] }, "vessel-records": { - "exports": ["./layers/vessels/records"], + "exports": [ + "./layers/vessels/records" + ], "modules": [ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js" @@ -2707,7 +3143,9 @@ "external": [] }, "vessel-ingestion": { - "exports": ["./layers/vessels/ingestion"], + "exports": [ + "./layers/vessels/ingestion" + ], "modules": [ "src/layers/vessels/ingestion.js", "src/layers/vessels/recordPolicy.js" @@ -2715,46 +3153,85 @@ "external": [] }, "directions-layer": { - "exports": ["./layers/directions"], - "modules": ["src/layers/directions/index.js", "src/data/routeSteps.js"], - "external": ["cesium"] + "exports": [ + "./layers/directions" + ], + "modules": [ + "src/layers/directions/index.js", + "src/data/routeSteps.js" + ], + "external": [ + "cesium" + ] }, "radio-browser-source": { - "exports": ["./sources/radio-browser"], - "modules": ["src/sources/radioBrowser.js", "src/data/radioCountry.js"], + "exports": [ + "./sources/radio-browser" + ], + "modules": [ + "src/sources/radioBrowser.js", + "src/data/radioCountry.js" + ], "external": [] }, "cctv-types-source": { - "exports": ["./sources/cctv-types"], - "modules": ["src/sources/cctvTypes.js"], + "exports": [ + "./sources/cctv-types" + ], + "modules": [ + "src/sources/cctvTypes.js" + ], "external": [] }, "regional-source": { - "exports": ["./sources/regional"], - "modules": ["src/data/regionalModel.js"], + "exports": [ + "./sources/regional" + ], + "modules": [ + "src/data/regionalModel.js" + ], "external": [] }, "voice-action-schemas": { - "exports": ["./voice/action-schemas"], - "modules": ["src/voice/actionSchemas.js", "src/hudLayoutPolicy.js"], + "exports": [ + "./voice/action-schemas" + ], + "modules": [ + "src/voice/actionSchemas.js", + "src/hudLayoutPolicy.js" + ], "external": [] }, "voice-session": { - "exports": ["./voice/session"], - "modules": ["src/voice/session.js"], + "exports": [ + "./voice/session" + ], + "modules": [ + "src/voice/session.js" + ], "external": [] }, "voice-session-controls": { - "exports": ["./voice/session-controls"], + "exports": [ + "./voice/session-controls" + ], "modules": [ - "src/voice/sessionCommands.js", + "src/i18n/index.js", + "src/i18n/locale.js", + "src/i18n/locales/en/cockpit.js", + "src/i18n/locales/en/layers.js", + "src/i18n/locales/en/setup.js", + "src/i18n/locales/en/shell.js", + "src/voice/control.js", "src/voice/session.js", - "src/voice/control.js" + "src/voice/sessionCommands.js" ], "external": [] }, "alpr-source": { - "exports": ["./layers/alpr/source"], + "exports": [ + "./layers/alpr/source" + ], "modules": [ "src/layers/alpr/policy.js", "src/layers/alpr/records.js", @@ -2765,20 +3242,34 @@ "src/sources/vectorTiles.js", "src/sources/phaseTiming.js" ], - "external": ["@mapbox/vector-tile", "pbf"] + "external": [ + "@mapbox/vector-tile", + "pbf" + ] }, "bikeshare-source": { - "exports": ["./layers/bikeshare/source"], - "modules": ["src/layers/bikeshare/source.js"], + "exports": [ + "./layers/bikeshare/source" + ], + "modules": [ + "src/layers/bikeshare/source.js" + ], "external": [] }, "cctv-source": { - "exports": ["./layers/cctv/source"], - "modules": ["src/layers/cctv/source.js", "src/layers/cctv/sourcePolicy.js"], + "exports": [ + "./layers/cctv/source" + ], + "modules": [ + "src/layers/cctv/source.js", + "src/layers/cctv/sourcePolicy.js" + ], "external": [] }, "earthquakes-source": { - "exports": ["./layers/earthquakes/source"], + "exports": [ + "./layers/earthquakes/source" + ], "modules": [ "src/layers/earthquakes/records.js", "src/layers/earthquakes/source.js" @@ -2786,12 +3277,18 @@ "external": [] }, "firms-source": { - "exports": ["./layers/firms/source"], - "modules": ["src/layers/firms/source.js"], + "exports": [ + "./layers/firms/source" + ], + "modules": [ + "src/layers/firms/source.js" + ], "external": [] }, "installations-source": { - "exports": ["./layers/installations/source"], + "exports": [ + "./layers/installations/source" + ], "modules": [ "src/layers/installations/source.js", "src/data/militaryInstallationData.js", @@ -2808,25 +3305,43 @@ "src/data/bundledJson.js", "src/data/retryableLoad.js" ], - "external": ["@mapbox/vector-tile", "pbf"] + "external": [ + "@mapbox/vector-tile", + "pbf" + ] }, "launches-source": { - "exports": ["./layers/launches/source"], - "modules": ["src/layers/launches/source.js"], + "exports": [ + "./layers/launches/source" + ], + "modules": [ + "src/layers/launches/source.js" + ], "external": [] }, "radio-source": { - "exports": ["./layers/radio/source"], - "modules": ["src/layers/radio/policy.js", "src/layers/radio/source.js"], + "exports": [ + "./layers/radio/source" + ], + "modules": [ + "src/layers/radio/policy.js", + "src/layers/radio/source.js" + ], "external": [] }, "satellites-source": { - "exports": ["./layers/satellites/source"], - "modules": ["src/layers/satellites/source.js"], + "exports": [ + "./layers/satellites/source" + ], + "modules": [ + "src/layers/satellites/source.js" + ], "external": [] }, "traffic-source-factory": { - "exports": ["./layers/traffic/source"], + "exports": [ + "./layers/traffic/source" + ], "modules": [ "src/data/tomtomTiles.js", "src/layers/traffic/flowDecode.js", @@ -2842,10 +3357,16 @@ "src/layers/traffic/roadModes.js", "src/data/flowMatch.js" ], - "external": ["@mapbox/vector-tile", "pbf"] + "external": [ + "@mapbox/vector-tile", + "pbf" + ] }, "reference-sources": { - "exports": ["./sources/reference", "./layers/submarine-cables/source"], + "exports": [ + "./sources/reference", + "./layers/submarine-cables/source" + ], "modules": [ "src/sources/reference.js", "src/layers/earthquakes/source.js", @@ -2858,7 +3379,9 @@ "external": [] }, "nominatim-provider": { - "exports": ["./search/nominatim"], + "exports": [ + "./search/nominatim" + ], "modules": [ "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -2871,17 +3394,27 @@ "external": [] }, "source-http-body": { - "exports": ["./sources/http-body"], - "modules": ["src/sources/httpBody.js"], + "exports": [ + "./sources/http-body" + ], + "modules": [ + "src/sources/httpBody.js" + ], "external": [] }, "overpass-lexer": { - "exports": ["./sources/overpass"], - "modules": ["src/sources/overpass.js"], + "exports": [ + "./sources/overpass" + ], + "modules": [ + "src/sources/overpass.js" + ], "external": [] }, "nominatim-source": { - "exports": ["./sources/nominatim"], + "exports": [ + "./sources/nominatim" + ], "modules": [ "src/sources/nominatim.js", "src/nominatimGeocode.js", @@ -2893,7 +3426,9 @@ }, "transit-provider": { "runtime": "node", - "exports": ["./server/providers/transit"], + "exports": [ + "./server/providers/transit" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/rate-limit.js", @@ -2908,20 +3443,28 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": ["pbf"] + "external": [ + "pbf" + ] }, "transit-source": { - "exports": ["./sources/transit"], + "exports": [ + "./sources/transit" + ], "modules": [ "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", "src/data/transitProxy.js", "src/sources/transitHistory.js" ], - "external": ["pbf"] + "external": [ + "pbf" + ] }, "transit-standalone": { - "exports": ["./layers/transit/standalone"], + "exports": [ + "./layers/transit/standalone" + ], "modules": [ "src/cyberSonar.js", "src/app/sourceSlot.js", @@ -2980,10 +3523,16 @@ "src/sources/featureSource.js", "src/sources/capability.js" ], - "external": ["cesium", "egm96-universal", "pbf"] + "external": [ + "cesium", + "egm96-universal", + "pbf" + ] }, "transit-layer": { - "exports": ["./layers/transit"], + "exports": [ + "./layers/transit" + ], "modules": [ "src/data/iconOrientation.js", "src/data/inputOwnership.js", @@ -3017,10 +3566,15 @@ "src/layers/transit/source.js", "src/sources/transitHistory.js" ], - "external": ["cesium", "pbf"] + "external": [ + "cesium", + "pbf" + ] }, "transit-source-factory": { - "exports": ["./layers/transit/source"], + "exports": [ + "./layers/transit/source" + ], "modules": [ "src/layers/transit/source.js", "src/sources/transitHistory.js" @@ -3028,7 +3582,9 @@ "external": [] }, "transit-service": { - "exports": ["./sources/transit-service"], + "exports": [ + "./sources/transit-service" + ], "modules": [ "src/data/transitFeeds.js", "src/data/transitProxy.js", @@ -3039,10 +3595,14 @@ "src/sources/transitHistoryStore.js", "src/sources/rateLimit.js" ], - "external": ["pbf"] + "external": [ + "pbf" + ] }, "map-feature-source": { - "exports": ["./sources/map-features"], + "exports": [ + "./sources/map-features" + ], "modules": [ "src/sources/featureGeometry.js", "src/sources/overpassFeaturesRecords.js", @@ -3053,7 +3613,9 @@ "external": [] }, "perimeters": { - "exports": ["./layers/perimeters"], + "exports": [ + "./layers/perimeters" + ], "modules": [ "src/layers/perimeters/index.js", "src/sources/httpBody.js", @@ -3062,10 +3624,14 @@ "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "perimeters-source": { - "exports": ["./layers/perimeters/source"], + "exports": [ + "./layers/perimeters/source" + ], "modules": [ "src/layers/perimeters/records.js", "src/sources/httpBody.js", diff --git a/src/app/controls.js b/src/app/controls.js index 8d9b988b5..9b9bc0b98 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -2,6 +2,7 @@ import { catalogControlServices } from './catalog.js'; import { StyleManager } from '../ui/composition.js'; import { flyToAustin } from '../camera.js'; import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; +import { t } from '../i18n/index.js'; /** Construct the existing controls and camera presentation. */ export function createApplicationControls({ @@ -41,10 +42,10 @@ export function createApplicationControls({ // If no share link state, do default fly-to Austin if (!styleManager.hasShareState) { - loaderStatus.textContent = 'Flying to Austin, TX...'; + loaderStatus.textContent = t('shell.loading.status.flying'); defer(flyToAustin(viewer)); } else { - loaderStatus.textContent = 'Restoring shared view...'; + loaderStatus.textContent = t('shell.loading.status.restoring'); } return { styleManager, weatherEffects, cockpitCloudEffects }; diff --git a/src/app/scene.js b/src/app/scene.js index 65e40cd7e..986ad7bc7 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -14,6 +14,7 @@ import { governorRequestRender, } from '../renderGovernor.js'; import { describeError } from './errors.js'; +import { t } from '../i18n/index.js'; /** Construct the application globe using the caller's local configuration. */ export async function createApplicationScene({ @@ -41,7 +42,7 @@ export async function createApplicationScene({ else window.__GOOGLE_MAPS_API_KEY__ = previousKey; }); } - loaderStatus.textContent = 'Configuring viewer...'; + loaderStatus.textContent = t('shell.loading.status.configuring'); // Provider attribution stays visible, including clean-view and recording. const creditContainer = document.createElement('div'); creditContainer.id = 'cesium-credits'; @@ -60,8 +61,8 @@ export async function createApplicationScene({ configureCreditKeyboardAccess(document); loaderStatus.textContent = googleApiKey || cesiumToken - ? 'Loading Google 3D Tiles...' - : 'Loading the keyless globe...'; + ? t('shell.loading.status.tilesGoogle') + : t('shell.loading.status.tilesKeyless'); const photoreal = await loadPhotorealisticTileset(Cesium, { googleApiKey, cesiumToken, @@ -88,12 +89,14 @@ export async function createApplicationScene({ tileError, ); const tileErrorDetail = describeError(tileError); - loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`; + loaderStatus.textContent = t('shell.loading.status.tilesUnavailable', { + detail: tileErrorDetail, + }); } viewer.scene.globe.show = true; } - loaderStatus.textContent = 'Initializing systems...'; + loaderStatus.textContent = t('shell.loading.status.systems'); const mapStackController = new MapController(viewer, { requestRender: governorRequestRender, diff --git a/src/creditKeyboard.js b/src/creditKeyboard.js index 5e1c3dea2..7d7c19cdb 100644 --- a/src/creditKeyboard.js +++ b/src/creditKeyboard.js @@ -2,6 +2,8 @@ * Adds the keyboard semantics omitted by Cesium's href-less attribution links. * Cesium retains ownership of showing and hiding its required credit lightbox. */ +import { t } from './i18n/index.js'; + export function configureCreditKeyboardAccess(root = document) { const expand = root?.querySelector?.( '#cesium-credits .cesium-credit-expand-link', @@ -19,7 +21,7 @@ export function configureCreditKeyboardAccess(root = document) { expand.setAttribute('aria-expanded', 'false'); close.setAttribute('role', 'button'); close.setAttribute('tabindex', '0'); - close.setAttribute('aria-label', 'Close data attribution'); + close.setAttribute('aria-label', t('shell.credits.closeAria')); if (expand.dataset.gevKeyboardReady === 'true') return true; expand.dataset.gevKeyboardReady = 'true'; diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index e4deae82e..e1f5e0232 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -301,7 +301,7 @@ test('Contacts continuation target is the visible explanatory note', () => { const source = readLayerSource(new URL('./militaryAwareness.js', import.meta.url)); assert.match( source, - /

Open-source mapped\/observed context\./, + /

\$\{t\('layers\.awareness\.note'\)\}<\/p>/, ); assert.doesNotMatch(source, /]*data-awareness-focus-continuation/); }); diff --git a/src/data/militaryAwarenessEngine.js b/src/data/militaryAwarenessEngine.js index 996ad5b81..17886e8fa 100644 --- a/src/data/militaryAwarenessEngine.js +++ b/src/data/militaryAwarenessEngine.js @@ -1,5 +1,7 @@ /** Pure proximity helpers for Global Context. No capability modeling. */ +import { t } from '../i18n/index.js'; + export const AWARENESS_RADIUS_M = 250000; export const AWARENESS_MAX_FLIGHT_SEARCH_RADIUS_M = 16000000; export const AWARENESS_RELATIONSHIP = Object.freeze({ @@ -82,14 +84,14 @@ export function summarizeAwarenessCohort( relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: 'feed unavailable', + reason: t('layers.awareness.reason.feedUnavailable'), }; if (stale) return { relationship: AWARENESS_RELATIONSHIP.UNKNOWN, count: null, nearest: [], - reason: 'feed stale', + reason: t('layers.awareness.reason.feedStale'), }; const normalized = (Array.isArray(items) ? items : []) .filter( @@ -106,8 +108,8 @@ export function summarizeAwarenessCohort( count: normalized.length, nearest: normalized.slice(0, limit), reason: normalized.length - ? 'observed or mapped nearby context' - : 'no observed or mapped objects in current feeds', + ? t('layers.awareness.reason.nearby') + : t('layers.awareness.reason.none'), }; } diff --git a/src/data/satelliteClass.js b/src/data/satelliteClass.js index 29a7df3b1..83ea0e846 100644 --- a/src/data/satelliteClass.js +++ b/src/data/satelliteClass.js @@ -17,6 +17,8 @@ * for), then the constellations users can actually name, then the grab-bag. */ +import { t } from '../i18n/index.js'; + /** * Class registry. `label` is the card/legend token, `color` the point color, * `blurb` the plain-language gloss for the legend tooltip. @@ -77,6 +79,32 @@ export const SATELLITE_CLASS_ORDER = Object.freeze([ 'comms', ]); +// Display keys for the class table above: `label`/`blurb` stay the English +// source-of-truth tokens (tests read them), while the card and legend accessors +// translate through these keys at the presentation boundary. +const CLASS_DISPLAY_KEYS = Object.freeze({ + station: Object.freeze({ + label: 'layers.satellites.class.station', + blurb: 'layers.satellites.class.stationBlurb', + }), + nav: Object.freeze({ + label: 'layers.satellites.class.nav', + blurb: 'layers.satellites.class.navBlurb', + }), + geo: Object.freeze({ + label: 'layers.satellites.class.geo', + blurb: 'layers.satellites.class.geoBlurb', + }), + visual: Object.freeze({ + label: 'layers.satellites.class.visual', + blurb: 'layers.satellites.class.visualBlurb', + }), + comms: Object.freeze({ + label: 'layers.satellites.class.comms', + blurb: 'layers.satellites.class.commsBlurb', + }), +}); + /** * CelesTrak group tag → { class, subtype }. Subtype names the specific * constellation so the card can read "NAV · GPS" rather than a bare "NAV". @@ -139,7 +167,7 @@ export function satelliteClassColor(group) { */ export function satelliteClassLabel(group, { isIss = false } = {}) { const { klass, subtype } = satelliteClassOf(group, { isIss }); - const base = SATELLITE_CLASSES[klass].label; + const base = t(CLASS_DISPLAY_KEYS[klass].label); return subtype ? `${base} · ${subtype}` : base; } @@ -180,11 +208,12 @@ export function satelliteClassLegend(counts) { const count = counts?.[klass]; if (!(count > 0)) continue; const spec = SATELLITE_CLASSES[klass]; + const display = CLASS_DISPLAY_KEYS[klass]; result.push({ klass, - label: spec.label, + label: t(display.label), color: spec.color, - blurb: spec.blurb, + blurb: t(display.blurb), count, }); } diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js index 240e22828..6db97e93d 100644 --- a/src/data/vesselLabels.js +++ b/src/data/vesselLabels.js @@ -4,6 +4,8 @@ * after the vessel cards moved out of their dedicated canvas renderer. */ +import { t } from '../i18n/index.js'; + export const VESSEL_OVERLAY_SOURCE_ID = 'ais-live-vessels'; /** Existing selector grid size; one ambient winner is retained per cell. */ export const VESSEL_LABEL_GRID_PX = 118; @@ -64,6 +66,43 @@ const NUMERIC_TYPE_FAMILIES = { 9: 'OTHER', }; +// Display keys for the normalized English type tokens above. The tokens stay +// machine values — TYPE_STYLES matches on them and tests pin them — so card +// text translates only through this map at the presentation boundary. +const TYPE_DISPLAY_KEYS = Object.freeze({ + FISHING: 'layers.vessel.type.fishing', + TOWING: 'layers.vessel.type.towing', + DREDGER: 'layers.vessel.type.dredger', + 'DIVE OPS': 'layers.vessel.type.diveOps', + MILITARY: 'layers.vessel.type.military', + SAILING: 'layers.vessel.type.sailing', + PLEASURE: 'layers.vessel.type.pleasure', + PILOT: 'layers.vessel.type.pilot', + SAR: 'layers.vessel.type.sar', + TUG: 'layers.vessel.type.tug', + 'PORT TENDER': 'layers.vessel.type.portTender', + 'ANTI-POLLUTION': 'layers.vessel.type.antiPollution', + 'LAW ENFORCE': 'layers.vessel.type.lawEnforce', + MEDICAL: 'layers.vessel.type.medical', + 'HIGH-SPEED': 'layers.vessel.type.highSpeed', + PASSENGER: 'layers.vessel.type.passenger', + CARGO: 'layers.vessel.type.cargo', + TANKER: 'layers.vessel.type.tanker', + OTHER: 'layers.vessel.type.other', +}); + +/** + * Card-display form of a normalized AIS type: known tokens translate through + * the catalog; free-text types and unknown codes render unchanged. + * @param {string} type Raw AIS type. + * @returns {string} + */ +export function displayVesselType(type) { + const normalized = normalizeVesselType(type); + const key = TYPE_DISPLAY_KEYS[normalized.toUpperCase()]; + return key ? t(key) : normalized; +} + /** * Resolve an AIS type to display text: bare numeric ship-type codes map to * family names ("71" → "CARGO"); text types pass through unchanged. diff --git a/src/firstRunExperience.js b/src/firstRunExperience.js index 6dffb7c5e..c26a18a7b 100644 --- a/src/firstRunExperience.js +++ b/src/firstRunExperience.js @@ -21,6 +21,8 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js'; // Choosing a mission is deliberately NOT durable suppression: picking a mission // is enthusiasm, not "never show me this again". +import { t } from './i18n/index.js'; + /** Durable suppression. Written ONLY by the "Don't show this again" checkbox. */ export const FIRST_RUN_STORAGE_KEY = 'gev:first-run-mission:v1'; /** Per-session dismissal. Written by every close path; scoped to sessionStorage. */ @@ -34,10 +36,12 @@ export const FIRST_RUN_SESSION_KEY = 'gev:first-run-mission-session:v1'; */ export const ENVIRONMENTAL_LABEL_CHOICE = 'ENVIRONMENTAL'; -const ENVIRONMENTAL_LABELS = Object.freeze({ - ENVIRONMENTAL: Object.freeze({ title: 'ENVIRONMENTAL' }), - EARTH_WATCH: Object.freeze({ title: 'EARTH WATCH' }), - ACTIVE_EVENTS: Object.freeze({ title: 'ACTIVE EVENTS' }), +// Catalog keys per label choice. Resolved through t() at CALL time (init), +// never at module load — the locale is not resolved until main.js's body runs. +const ENVIRONMENTAL_LABEL_KEYS = Object.freeze({ + ENVIRONMENTAL: 'setup.firstRun.environmentalTitle.environmental', + EARTH_WATCH: 'setup.firstRun.environmentalTitle.earthWatch', + ACTIVE_EVENTS: 'setup.firstRun.environmentalTitle.activeEvents', }); /** @@ -45,7 +49,9 @@ const ENVIRONMENTAL_LABELS = Object.freeze({ * @returns {{title: string}} The label set the constant above selects. */ export function environmentalLabel(choice = ENVIRONMENTAL_LABEL_CHOICE) { - return ENVIRONMENTAL_LABELS[choice] || ENVIRONMENTAL_LABELS.ENVIRONMENTAL; + const key = + ENVIRONMENTAL_LABEL_KEYS[choice] || ENVIRONMENTAL_LABEL_KEYS.ENVIRONMENTAL; + return { title: t(key) }; } /* @@ -92,12 +98,12 @@ export const FIRST_RUN_MISSIONS = Object.freeze({ contacts: Object.freeze({ kind: 'context', contextMode: 'contacts', - busyText: 'Starting live contacts…', + busyKey: 'setup.firstRun.busy.contacts', }), 'space-missions': Object.freeze({ kind: 'context', contextMode: 'space-missions', - busyText: 'Opening space missions…', + busyKey: 'setup.firstRun.busy.spaceMissions', }), environmental: Object.freeze({ kind: 'globe', @@ -115,7 +121,7 @@ export const FIRST_RUN_MISSIONS = Object.freeze({ // before a launch. LEDGERED post-launch. Until it lands, keyless visitors // are judged on the layer row, which tells them the truth. layerIds: Object.freeze(['earthquakes', 'local-firms']), - busyText: 'Scanning active events…', + busyKey: 'setup.firstRun.busy.environmental', }), explore: Object.freeze({ kind: 'none' }), }); @@ -352,12 +358,23 @@ export function initFirstRunExperience({ } // The tile name is owner-switchable from one constant, so paint it from the - // module rather than trusting the markup to have been edited to match. + // module rather than trusting the markup to have been edited to match. The + // persuasive description line and the tile's feed subcopy are the two + // phase-2 exceptions whose static sites are pinned verbatim by + // firstRunExperience.test.mjs — the runtime write is their only legal + // localization point (docs/TRANSLATORS.md, runtime-only static sites). const environmentalTitle = root.querySelector( '[data-first-run-environmental-title]', ); if (environmentalTitle) environmentalTitle.textContent = environmentalLabel().title; + const environmentalSub = root.querySelector( + '[data-first-run-environmental-title] + small', + ); + if (environmentalSub) + environmentalSub.textContent = t('setup.firstRun.choice.environmentalSub'); + const description = root.querySelector('#first-run-description'); + if (description) description.textContent = t('setup.firstRun.description'); const status = root.querySelector('[data-first-run-status]'); const suppressBox = root.querySelector('[data-first-run-suppress]'); @@ -440,7 +457,9 @@ export function initFirstRunExperience({ button.setAttribute('aria-disabled', String(next)); if (!status) return; if (next) - status.textContent = FIRST_RUN_MISSIONS[choice]?.busyText || 'Working…'; + status.textContent = t( + FIRST_RUN_MISSIONS[choice]?.busyKey || 'setup.firstRun.busy.working', + ); else if (status.dataset.sticky !== 'true') status.textContent = defaultStatus; }; @@ -490,7 +509,7 @@ export function initFirstRunExperience({ Array.isArray(failed) && failed.length ? ` (${failed.join(', ')})` : ''; if (status) { status.dataset.sticky = 'true'; - status.textContent = `Could not open that mission${detail}. Retry or explore manually.`; + status.textContent = t('setup.firstRun.status.failed', { detail }); } setBusy(false); }; @@ -506,8 +525,7 @@ export function initFirstRunExperience({ if (box) box.checked = !wanted; if (!status) return; status.dataset.sticky = 'true'; - status.textContent = - 'This browser is blocking storage, so that could not be saved.'; + status.textContent = t('setup.firstRun.status.storageBlocked'); }; const keyboard = createSurfaceKeyboard({ diff --git a/src/hud.js b/src/hud.js index eb8c0ab34..f251c0193 100644 --- a/src/hud.js +++ b/src/hud.js @@ -35,6 +35,7 @@ import { DEFAULT_HUD_LAYOUT, normalizeHudLayout, } from './hudLayouts.js'; +import { t } from './i18n/index.js'; /** Color palettes keyed by shader mode; applied as CSS custom properties. */ const HUD_COLORS = { @@ -201,15 +202,15 @@ export class IntelHUD {

${this._missionId} ${this._sensorId}
NORMAL
-
SUMMARY
-
Awaiting telemetry...
+
${t('cockpit.hud.summaryLabel')}
+
${t('cockpit.hud.summaryAwaiting')}
-
● REC 2026-01-01 00:00:00Z
+
● ${t('cockpit.hud.recLabel')} 2026-01-01 00:00:00Z
ORB: ${this._orbitNum} PASS: DESC-${this._passNum}
┐
@@ -634,7 +635,7 @@ export class IntelHUD { */ _composeSummary() { const m = this._latestMetrics; - if (!m) return 'Awaiting telemetry...'; + if (!m) return t('cockpit.hud.summaryAwaiting'); const modeEl = document.getElementById('hud-mode'); const modeLabel = modeEl?.textContent || 'NORMAL'; diff --git a/src/hudA11y.test.mjs b/src/hudA11y.test.mjs index 3d11ebf3e..920933852 100644 --- a/src/hudA11y.test.mjs +++ b/src/hudA11y.test.mjs @@ -22,7 +22,9 @@ test('HUD sliders and location search have descriptive explicit names', () => { }); test('the first-run checkbox keeps its native visible label', () => { - assert.match(html, /]*class="first-run-suppress"[^>]*>\s*\s*Don't show this again<\/span>\s*<\/label>/); + // The span may carry a data-i18n extraction attribute; the native wrapping + // label contract is what this guards. + assert.match(html, /]*class="first-run-suppress"[^>]*>\s*\s*]*>Don't show this again<\/span>\s*<\/label>/); }); test('generated style sliders use the visible parameter label as their name', () => { diff --git a/src/i18n/locales/en/layers.js b/src/i18n/locales/en/layers.js index 458a648f7..3b66325d6 100644 --- a/src/i18n/locales/en/layers.js +++ b/src/i18n/locales/en/layers.js @@ -210,6 +210,7 @@ export default { 'cctv.summary.empty': 'No cameras available in catalog.', 'cctv.summary.city': '{city} CCTV', 'cctv.summary.hdg': 'HDG {value}°', + 'cctv.summary.hdgEstimated': 'HDG {value}° (ESTIMATED)', 'cctv.summary.fov': 'FOV {value}°', 'cctv.summary.coverage': 'COVERAGE {value}km²', 'cctv.summary.overlap': 'OVERLAP {count} cams', @@ -415,8 +416,21 @@ export default { 'firms.card.focusAria': 'Focus fire detection {title}, {details}', // src/data/traffic.js feed presentation (manager meta loadingLabel values). - 'traffic.loadingSyncing': 'syncing LIVE traffic flow', - 'traffic.liveCoverage': 'LIVE · TomTom flow · {percent}% cov', + 'traffic.loadingSyncing': + 'Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated', + 'traffic.liveCoverage': + 'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov', + 'traffic.liveCoverageUnmatched': + 'LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated', + 'traffic.simulatedNoMatches': + 'SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)', + 'traffic.syncingRoads': 'Syncing flow · Roads: {source}', + 'traffic.liveRoadsFlowHidden': + 'LIVE · Roads: {source} · Roads without flow hidden', + 'traffic.liveRoadsNoFlowInView': + 'LIVE · Roads: {source} · No flow roads in view', + 'traffic.roadsFlowLive': 'LIVE · Roads: {source} · Flow {percent}%', + 'traffic.roadsFlowSimulated': 'SIMULATED · Roads: {source} · Flow {percent}%', 'traffic.simUnavailable': 'SIMULATED — traffic service unreachable', 'traffic.simKeyless': 'SIMULATED — add TomTom key for live', @@ -470,6 +484,7 @@ export default { 'awareness.cohort.vessels': 'AIS vessels', 'awareness.cohort.installations': 'Mapped installations', 'awareness.coverage.viewport': 'CURRENT VIEWPORT ONLY', + 'awareness.coverage.within': 'WITHIN {km} KM', 'awareness.hdg': 'HDG {value}°', 'awareness.brg': 'BRG {value}°', 'awareness.brgUnknown': 'BRG —', diff --git a/src/i18n/markupCoverage.test.mjs b/src/i18n/markupCoverage.test.mjs new file mode 100644 index 000000000..45c8743eb --- /dev/null +++ b/src/i18n/markupCoverage.test.mjs @@ -0,0 +1,128 @@ +// Markup coverage gate (phase 2): every data-i18n* attribute in the static +// application markup must name a key that resolves in EVERY shipped catalog +// via the real catalog builder — a renamed or dropped catalog key can no +// longer strand a static extraction silently behind the key-itself fallback. +// index.html is now assembled from src/ui/templates/*.html by +// build/application-html.js before Vite processes it, so the gate expands the +// same templates the build serves and scans that assembled markup. Regex +// parsing is deliberate: no DOM dependency in unit tests. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { getCatalog } from './index.js'; +import { CATALOG_LOCALES, availableLocales } from './locale.js'; +import { expandApplicationHtml } from '../../build/application-html.js'; + +const INDEX_HTML = new URL('../../index.html', import.meta.url); +const UI_JS = new URL('../../src/ui/applicationShell.js', import.meta.url); + +// The four spellings applyDocumentTranslations() knows about. Anything else +// (a typo like data-i18n-lable) must fail here, not silently never apply. +const I18N_ATTRIBUTE_PATTERN = /data-i18n(?:-title|-aria-label|-placeholder)?="([^"]+)"/g; +const KNOWN_ATTRIBUTE_NAMES = new Set([ + 'data-i18n', + 'data-i18n-title', + 'data-i18n-aria-label', + 'data-i18n-placeholder', +]); + +const html = expandApplicationHtml(readFileSync(INDEX_HTML, 'utf8')); + +const references = [...html.matchAll(I18N_ATTRIBUTE_PATTERN)].map((match) => match[1]); + +test('index.html carries static i18n references', () => { + assert.ok(references.length > 0, 'no data-i18n* attributes found — extraction missing?'); +}); + +test('index.html sources its body from the application templates', () => { + const raw = readFileSync(INDEX_HTML, 'utf8'); + assert.ok(raw.includes(' -
- LEVEL + LEVEL
-
- GROUND SPEED · KTS + GROUND SPEED · KTS
@@ -92,7 +92,7 @@
- ALTITUDE · FT + ALTITUDE · FT
@@ -112,36 +112,36 @@
- FIRST PERSON + FIRST PERSON AIRCRAFT - LIVE TRACK · COURSE ALIGNED + LIVE TRACK · COURSE ALIGNED
-
- - -
-
+
- GROUND SPEED + GROUND SPEED --- KTS
---°
- ALTITUDE + ALTITUDE ----- FT
@@ -153,26 +153,26 @@ --:--:--Z --° · --°
-
- MAP SOURCE + MAP SOURCE 3D
-
+
Style - NORMAL + NORMAL
@@ -72,7 +72,7 @@
diff --git a/src/ui/templates/context.html b/src/ui/templates/context.html index a1f3ae77b..290726417 100644 --- a/src/ui/templates/context.html +++ b/src/ui/templates/context.html @@ -4,10 +4,10 @@
- WEATHER - 0 + WEATHER + 0 - +
@@ -16,10 +16,10 @@
- RECENT IMAGERY - + RECENT IMAGERY + - +
@@ -75,7 +75,7 @@
SELECT CONTEXT - CONTACTS — nearest planes · vessels · sites
SPACE MISSIONS — launches & orbital assets
+ CONTACTS — nearest planes · vessels · sites
SPACE MISSIONS — launches & orbital assets