From 9c638fa2907ec10273a8ed3d3c379bad5ce87da1 Mon Sep 17 00:00:00 2001 From: onionviolet <142947238+onionviolet@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:48:31 -0400 Subject: [PATCH 001/155] refactor(geo): define shared distance contract --- CHANGELOG.md | 5 ++ docs/CURRENT-STATE.md | 8 +++ scripts/package-boundaries.json | 20 ++++++- src/annotations/annotationEngine.js | 12 +---- src/annotations/drawMode.js | 11 ++-- src/cockpitCloudEffects.js | 21 ++------ src/data/analystEngine.js | 13 ++--- src/data/naturalEarthRegions.js | 16 ++---- src/data/naturalEarthRegions.test.mjs | 11 ++++ src/data/regionalModel.js | 21 +++----- src/data/routePlausible.js | 40 +++++--------- src/data/routePlausible.test.mjs | 9 +++- src/data/trafficBounds.js | 29 +++------- src/data/trafficBounds.test.mjs | 14 ++--- src/data/transitFeeds.js | 13 ++--- src/geoDistance.js | 70 ++++++++++++++++++++++++ src/geoDistance.test.mjs | 78 +++++++++++++++++++++++++++ src/hud.js | 9 +--- src/layers/bikeshare/model.js | 12 ++--- src/layers/cctv/model.js | 8 +-- src/locations.js | 19 ++----- src/voice/gevActions.js | 23 +++----- 22 files changed, 267 insertions(+), 195 deletions(-) create mode 100644 src/geoDistance.js create mode 100644 src/geoDistance.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 261946f7a..432125bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +- Share one validated great-circle distance utility across runtime consumers. + Canonical coordinates use `{ lat, lon }`, invalid points stay outside spatial + selections, kilometres and metres share the IUGG mean Earth radius, and + compatibility `haversineKm` exports delegate to the canonical implementation. + - Add Director import previews, validated scene/shot detail drafts and selected-scene JSON or asset-bundle sharing. Preserve attribution; verify bounded bundle bytes before admission and release staged work on cancellation or teardown. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bd7d6cfd7..be65908d2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,13 @@ # God's Eye View Current State +Shared distance checks use `geoDistance.js` with `{ lat, lon }` points and the +IUGG mean spherical Earth radius. `greatCircleKm` is canonical; +`greatCircleMeters` is its exact metre conversion. Missing, non-finite, and +out-of-range points return `Infinity`, so threshold and nearest-item consumers +exclude invalid positions. Older exported `haversineKm` entry points remain as +compatibility wrappers only. Intentional local angular, projection, ellipsoidal +prefilter, and small-viewport approximations remain separate. + Director imports now open a non-mutating preview before Apply. EDIT DETAILS authors validated anchor, camera, pack and interaction drafts; SHARE SCENE exports a selected scene or a bounded bundle of explicitly chosen pack files. Bundled diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 433f5f36a..632984059 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -246,6 +246,7 @@ "server/providers/regional/weather-effects.js", "server/providers/regional/weather.js", "src/data/regionalModel.js", + "src/geoDistance.js", "src/nominatimGeocode.js", "src/sources/httpBody.js", "src/sources/rateLimit.js" @@ -303,6 +304,7 @@ "src/search/nominatim.js", "src/nominatimGeocode.js", "src/data/regionalModel.js", + "src/geoDistance.js", "src/sources/httpBody.js", "src/sources/nominatim.js" ], @@ -713,6 +715,7 @@ "src/data/motionModel.js", "src/data/renderAltitude.js", "src/data/routePlausible.js", + "src/geoDistance.js", "src/data/trackedModelRegime.js", "src/data/trackingClickGesture.js", "src/layers/flights/controller.js", @@ -786,6 +789,7 @@ "src/data/motionModel.js", "src/data/renderAltitude.js", "src/data/routePlausible.js", + "src/geoDistance.js", "src/data/trackedModelRegime.js", "src/data/trackingClickGesture.js", "src/layers/aircraft/classification.js", @@ -953,6 +957,7 @@ "src/data/flowMatch.js", "src/data/tomtomTiles.js", "src/data/trafficBounds.js", + "src/geoDistance.js", "src/data/trafficFlowStyle.js", "src/data/trafficPresetStyle.js", "src/data/trafficQueue.js", @@ -984,6 +989,7 @@ "src/layers/bikeshare/ingestion.js", "src/layers/bikeshare/lifecycle.js", "src/layers/bikeshare/model.js", + "src/geoDistance.js", "src/layers/bikeshare/policy.js", "src/layers/bikeshare/queries.js", "src/layers/bikeshare/registry.js", @@ -1021,6 +1027,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/geoDistance.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", @@ -1115,6 +1122,7 @@ "exports": ["./geography/natural-earth", "./geography/neighborhoods"], "modules": [ "src/data/naturalEarthRegions.js", + "src/geoDistance.js", "src/data/neighborhoodPolygons.js", "src/data/retryableLoad.js", "src/data/local_data/neighborhoods/san-francisco.json", @@ -1208,6 +1216,7 @@ "src/data/bhoteKoshiFloodPath.js", "src/data/bhoteKoshiShotPaths.js", "src/annotations/annotationEngine.js", + "src/geoDistance.js", "src/annotations/annotationResolver.js", "src/annotations/hybridAnnotationRenderer.js", "src/annotations/index.js", @@ -1834,6 +1843,7 @@ "src/data/bhoteKoshiIncidentPlaces.js", "src/data/bhoteKoshiFloodPath.js", "src/data/bhoteKoshiShotPaths.js", + "src/geoDistance.js", "src/annotations/annotationResolver.js", "src/annotations/resolver.js", "src/app/catalog.js", @@ -2224,6 +2234,7 @@ "src/app/surfaceServices.js", "src/data/dataCredits.js", "src/data/geoid.js", + "src/geoDistance.js", "src/data/local_data/natural_earth/marine.json", "src/data/local_data/natural_earth/regions.json", "src/data/local_data/neighborhoods/san-francisco.json", @@ -2405,7 +2416,7 @@ }, "regional-source": { "exports": ["./sources/regional"], - "modules": ["src/data/regionalModel.js"], + "modules": ["src/data/regionalModel.js", "src/geoDistance.js"], "external": [] }, "voice-action-schemas": { @@ -2511,6 +2522,7 @@ "src/search/google.js", "src/search/geospatial.js", "src/data/regionalModel.js", + "src/geoDistance.js", "src/sources/httpBody.js", "src/sources/nominatim.js" ], @@ -2533,6 +2545,7 @@ "src/nominatimGeocode.js", "src/search/geospatial.js", "src/data/regionalModel.js", + "src/geoDistance.js", "src/sources/httpBody.js" ], "external": [] @@ -2547,6 +2560,7 @@ "server/providers/transitHistory.js", "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", + "src/geoDistance.js", "src/data/transitProxy.js", "src/sources/httpBody.js", "src/sources/transitHistory.js", @@ -2561,6 +2575,7 @@ "modules": [ "src/data/gtfsRealtime.js", "src/data/transitFeeds.js", + "src/geoDistance.js", "src/data/transitProxy.js", "src/sources/transitHistory.js" ], @@ -2580,6 +2595,7 @@ "src/data/geoid.js", "src/data/groundFloor.js", "src/data/gtfsRealtime.js", + "src/geoDistance.js", "src/data/iconOrientation.js", "src/data/inputOwnership.js", "src/data/labelArbiter.js", @@ -2632,6 +2648,7 @@ "src/data/iconOrientation.js", "src/data/inputOwnership.js", "src/data/transitFeeds.js", + "src/geoDistance.js", "src/data/transitIcons.js", "src/data/transitPresetStyle.js", "src/layers/transit/controls.js", @@ -2675,6 +2692,7 @@ "exports": ["./sources/transit-service"], "modules": [ "src/data/transitFeeds.js", + "src/geoDistance.js", "src/data/transitProxy.js", "src/data/gtfsRealtime.js", "src/sources/httpBody.js", diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index ccf9cb06a..529dff5cc 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -1,5 +1,6 @@ import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; +import { greatCircleMeters } from '../geoDistance.js'; import { holdContinuousRender, releaseContinuousRender, @@ -1530,16 +1531,7 @@ async function fetchRoute(coordPairs, mode, signal, service) { } function greatCircleM(a, b) { - const R = 6371000; - const toRad = (deg) => (deg * Math.PI) / 180; - const dLat = toRad(b.lat - a.lat); - const dLon = toRad(b.lon - a.lon); - const la1 = toRad(a.lat); - const la2 = toRad(b.lat); - const h = - Math.sin(dLat / 2) ** 2 + - Math.cos(la1) * Math.cos(la2) * Math.sin(dLon / 2) ** 2; - return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); + return greatCircleMeters(a, b); } function formatDistance(m) { diff --git a/src/annotations/drawMode.js b/src/annotations/drawMode.js index f28a7ae6f..d8cde2ab8 100644 --- a/src/annotations/drawMode.js +++ b/src/annotations/drawMode.js @@ -18,6 +18,8 @@ * `drawTool.js`. */ +import { greatCircleMeters } from '../geoDistance.js'; + export const DRAW_SHAPES = Object.freeze(['area', 'line', 'pin']); export const MIN_VERTICES = Object.freeze({ area: 3, line: 2, pin: 1 }); /** @@ -47,14 +49,7 @@ export function createDrawSession(shape = 'area') { /** Great-circle distance in metres between two {lon, lat} points. */ export function greatCircleM(a, b) { - const R = 6371000; - const toRad = (d) => (d * Math.PI) / 180; - const dLat = toRad(b.lat - a.lat); - const dLon = toRad(b.lon - a.lon); - const h = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2; - return 2 * R * Math.asin(Math.sqrt(Math.min(1, h))); + return greatCircleMeters(a, b); } /** diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index 053217810..ccbf31831 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -1,5 +1,6 @@ import { applicationServices } from './services/application.js'; import * as Cesium from 'cesium'; +import { greatCircleMeters } from './geoDistance.js'; import { deriveWeatherEffectProfile, weatherAltitudeFactors, @@ -127,23 +128,9 @@ function compileShader(gl, type, source) { } function greatCircleM(a, b) { - if ( - ![a?.latitude, a?.longitude, b?.latitude, b?.longitude].every( - Number.isFinite, - ) - ) - return Infinity; - const latitudeA = Cesium.Math.toRadians(a.latitude); - const latitudeB = Cesium.Math.toRadians(b.latitude); - const latitudeDelta = Cesium.Math.toRadians(b.latitude - a.latitude); - const longitudeDelta = Cesium.Math.toRadians(b.longitude - a.longitude); - const haversine = - Math.sin(latitudeDelta / 2) ** 2 + - Math.cos(latitudeA) * - Math.cos(latitudeB) * - Math.sin(longitudeDelta / 2) ** 2; - return ( - 6371000 * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)) + return greatCircleMeters( + { lat: a?.latitude, lon: a?.longitude }, + { lat: b?.latitude, lon: b?.longitude }, ); } diff --git a/src/data/analystEngine.js b/src/data/analystEngine.js index a84d6b855..1d2baccaf 100644 --- a/src/data/analystEngine.js +++ b/src/data/analystEngine.js @@ -25,6 +25,7 @@ */ import { pointInRing } from './naturalEarthRegions.js'; +import { greatCircleKm } from '../geoDistance.js'; /** Layers the engine understands, with the fields queries may reference. */ export const ANALYST_LAYERS = { @@ -63,17 +64,9 @@ export const ANALYST_LAYERS = { }, }; -const EARTH_R_KM = 6371; - -/** Great-circle distance in km. */ +/** @deprecated Use geoDistance.greatCircleKm with `{ lat, lon }` points. */ export function haversineKm(lat1, lon1, lat2, lon2) { - const d2r = Math.PI / 180; - const dLat = (lat2 - lat1) * d2r; - const dLon = (lon2 - lon1) * d2r; - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(lat1 * d2r) * Math.cos(lat2 * d2r) * Math.sin(dLon / 2) ** 2; - return 2 * EARTH_R_KM * Math.asin(Math.min(1, Math.sqrt(a))); + return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 }); } /** One filter: {field, op:'gt'|'lt'|'gte'|'lte'|'eq'|'neq'|'contains', value}. */ diff --git a/src/data/naturalEarthRegions.js b/src/data/naturalEarthRegions.js index 891e7e9a1..c3664e266 100644 --- a/src/data/naturalEarthRegions.js +++ b/src/data/naturalEarthRegions.js @@ -17,8 +17,8 @@ */ import { createRetryableLoader } from './retryableLoad.js'; +import { EARTH_RADIUS_KM, greatCircleKm } from '../geoDistance.js'; -const EARTH_RADIUS_KM = 6371; const toRad = (d) => (d * Math.PI) / 180; /** Spherical-excess ring area (km²) — same family as turf/geojson-area. */ @@ -35,15 +35,6 @@ function ringAreaKm2(ring) { return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2); } -function haversineKm(lon1, lat1, lon2, lat2) { - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); - const h = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; - return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h))); -} - /** * Normalize a query/name for matching: lowercase, strip diacritics and * punctuation, collapse whitespace, strip a leading "the ". @@ -162,7 +153,10 @@ function buildEntries(pack, kind) { polygons, areaKm2, bbox: [minLon, minLat, maxLon, maxLat], - bboxDiagonalKm: haversineKm(minLon, minLat, maxLon, maxLat), + bboxDiagonalKm: greatCircleKm( + { lat: minLat, lon: minLon }, + { lat: maxLat, lon: maxLon }, + ), }); } return out; diff --git a/src/data/naturalEarthRegions.test.mjs b/src/data/naturalEarthRegions.test.mjs index 36c1946ad..5dd78adea 100644 --- a/src/data/naturalEarthRegions.test.mjs +++ b/src/data/naturalEarthRegions.test.mjs @@ -3,6 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { statSync, readFileSync } from 'node:fs'; import { findNaturalRegion, listRegions, lookupNaturalRegionOutline, pointInRing } from './naturalEarthRegions.js'; +import { greatCircleKm } from '../geoDistance.js'; const PACK_DIR = new URL('./local_data/natural_earth/', import.meta.url); @@ -19,6 +20,16 @@ test('marquee ranges resolve with sane areas (owner acceptance: Alps + Rockies)' assert.ok(rockies.areaKm2 >= 700_000 && rockies.areaKm2 < 1_500_000, `Rockies area sane (got ${Math.round(rockies.areaKm2)} km²)`); assert.ok(rockies.bboxDiagonalKm > 2000, 'Rockies span thousands of km'); + const coordinates = rockies.polygons.flat(); + const minLon = Math.min(...coordinates.map(([lon]) => lon)); + const minLat = Math.min(...coordinates.map(([, lat]) => lat)); + const maxLon = Math.max(...coordinates.map(([lon]) => lon)); + const maxLat = Math.max(...coordinates.map(([, lat]) => lat)); + assert.equal( + rockies.bboxDiagonalKm, + greatCircleKm({ lat: minLat, lon: minLon }, { lat: maxLat, lon: maxLon }), + 'Natural Earth bbox conversion maps [lon, lat] data into { lat, lon } points', + ); }); test('aliases and articles: "the Alps", "Rockies", "Sahara Desert", "Himalaya"', async () => { diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js index 08b4e9336..116697c6f 100644 --- a/src/data/regionalModel.js +++ b/src/data/regionalModel.js @@ -1,3 +1,5 @@ +import { greatCircleMeters } from '../geoDistance.js'; + const MAX_ARTICLES = 5; function cleanText(value, maxLength = 180) { @@ -136,19 +138,8 @@ export function weatherCodeLabel(code) { /** Great-circle distance used to avoid refetching a regional brief every animation frame. */ export function regionalDistanceM(from, to) { - if ( - ![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every( - Number.isFinite, - ) - ) { - return Infinity; - } - const phi1 = (from.latitude * Math.PI) / 180; - const phi2 = (to.latitude * Math.PI) / 180; - const deltaPhi = ((to.latitude - from.latitude) * Math.PI) / 180; - const deltaLambda = ((to.longitude - from.longitude) * Math.PI) / 180; - const a = - Math.sin(deltaPhi / 2) ** 2 + - Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2; - return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return greatCircleMeters( + { lat: from?.latitude, lon: from?.longitude }, + { lat: to?.latitude, lon: to?.longitude }, + ); } diff --git a/src/data/routePlausible.js b/src/data/routePlausible.js index a8c0e9259..238c2e6cb 100644 --- a/src/data/routePlausible.js +++ b/src/data/routePlausible.js @@ -10,19 +10,9 @@ * low → its destination should be nearby). */ -const D2R = Math.PI / 180; -const R_KM = 6371; +import { EARTH_RADIUS_KM, greatCircleKm, isGeoPoint } from '../geoDistance.js'; -/** Haversine great-circle distance in km. */ -export function greatCircleKm(lat1, lon1, lat2, lon2) { - const p1 = lat1 * D2R; - const p2 = lat2 * D2R; - const dp = (lat2 - lat1) * D2R; - const dl = (lon2 - lon1) * D2R; - const a = - Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2; - return R_KM * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} +const D2R = Math.PI / 180; function bearingRad(lat1, lon1, lat2, lon2) { const p1 = lat1 * D2R; @@ -36,10 +26,11 @@ function bearingRad(lat1, lon1, lat2, lon2) { /** Signed cross-track distance (km) of point from the great circle p1→p2. */ export function crossTrackKm(lat, lon, lat1, lon1, lat2, lon2) { - const d13 = greatCircleKm(lat1, lon1, lat, lon) / R_KM; + const d13 = + greatCircleKm({ lat: lat1, lon: lon1 }, { lat, lon }) / EARTH_RADIUS_KM; const b13 = bearingRad(lat1, lon1, lat, lon); const b12 = bearingRad(lat1, lon1, lat2, lon2); - return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * R_KM; + return Math.asin(Math.sin(d13) * Math.sin(b13 - b12)) * EARTH_RADIUS_KM; } const NEAR_ENDPOINT_KM = 130; @@ -65,14 +56,14 @@ export function routePlausible({ origin = null, destination = null, }) { - const haveO = Number.isFinite(origin?.lat) && Number.isFinite(origin?.lon); - const haveD = - Number.isFinite(destination?.lat) && Number.isFinite(destination?.lon); + const position = { lat: latDeg, lon: lonDeg }; + if (!isGeoPoint(position)) return true; + const haveO = isGeoPoint(origin); + const haveD = isGeoPoint(destination); if (!haveO && !haveD) return true; // no coordinates — cannot judge, do not hide // (a) Geographic consistency: near an endpoint, or roughly on the path. - const near = (pt) => - greatCircleKm(latDeg, lonDeg, pt.lat, pt.lon) < NEAR_ENDPOINT_KM; + const near = (point) => greatCircleKm(position, point) < NEAR_ENDPOINT_KM; let geomOk = (haveO && near(origin)) || (haveD && near(destination)); if (!geomOk && haveO && haveD) { geomOk = @@ -99,17 +90,10 @@ export function routePlausible({ Math.abs(verticalRateMps) > VERT_TREND_MPS ) { if (verticalRateMps > 0) { - if ( - haveO && - greatCircleKm(latDeg, lonDeg, origin.lat, origin.lon) > LOCAL_AIRPORT_KM - ) + if (haveO && greatCircleKm(position, origin) > LOCAL_AIRPORT_KM) return false; // departing — origin should be local } else { - if ( - haveD && - greatCircleKm(latDeg, lonDeg, destination.lat, destination.lon) > - LOCAL_AIRPORT_KM - ) + if (haveD && greatCircleKm(position, destination) > LOCAL_AIRPORT_KM) return false; // arriving — destination should be local } } diff --git a/src/data/routePlausible.test.mjs b/src/data/routePlausible.test.mjs index dab932c6e..aa59a0c18 100644 --- a/src/data/routePlausible.test.mjs +++ b/src/data/routePlausible.test.mjs @@ -1,14 +1,15 @@ // src/data/routePlausible.test.mjs import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { routePlausible, greatCircleKm } from './routePlausible.js'; +import { greatCircleKm } from '../geoDistance.js'; +import { routePlausible } from './routePlausible.js'; const SFO = { lat: 37.6188, lon: -122.3754 }; const LAX = { lat: 33.9416, lon: -118.4085 }; const JFK = { lat: 40.6413, lon: -73.7781 }; test('greatCircleKm sanity: SFO→LAX ≈ 543 km', () => { - const d = greatCircleKm(SFO.lat, SFO.lon, LAX.lat, LAX.lon); + const d = greatCircleKm(SFO, LAX); assert.ok(Math.abs(d - 543) < 15, `got ${d}`); }); @@ -41,4 +42,8 @@ test('missing data never hides a route (cannot judge → allow)', () => { latDeg: 51.5, lonDeg: -0.12, origin: { lat: null, lon: null }, destination: null, }), true); + assert.equal(routePlausible({ + latDeg: 95, lonDeg: 0, + origin: SFO, destination: LAX, + }), true); }); diff --git a/src/data/trafficBounds.js b/src/data/trafficBounds.js index 4bd80ff32..f41eff6a9 100644 --- a/src/data/trafficBounds.js +++ b/src/data/trafficBounds.js @@ -13,31 +13,11 @@ * @module data/trafficBounds */ -/** @const {number} Mean Earth radius in km (spherical approximation). */ -const EARTH_RADIUS_KM = 6371; +import { EARTH_RADIUS_KM, greatCircleKm, isGeoPoint } from '../geoDistance.js'; const toRad = (deg) => (deg * Math.PI) / 180; const toDeg = (rad) => (rad * 180) / Math.PI; -/** - * Great-circle distance between two lat/lon points (haversine). - * - * @param {number} lat1 - First point latitude (degrees). - * @param {number} lon1 - First point longitude (degrees). - * @param {number} lat2 - Second point latitude (degrees). - * @param {number} lon2 - Second point longitude (degrees). - * @returns {number} Distance in kilometres. - */ -export function greatCircleKm(lat1, lon1, lat2, lon2) { - const p1 = toRad(lat1); - const p2 = toRad(lat2); - const dp = toRad(lat2 - lat1); - const dl = toRad(lon2 - lon1); - const a = - Math.sin(dp / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) ** 2; - return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a))); -} - /** * Initial bearing (radians) from point 1 toward point 2 along the great circle. * @@ -111,10 +91,13 @@ export function deriveFetchCenter({ hitLon, maxPullKm = 12, }) { - if (!Number.isFinite(hitLat) || !Number.isFinite(hitLon)) { + if (!isGeoPoint({ lat: hitLat, lon: hitLon })) { return { lat: nadirLat, lon: nadirLon, source: 'nadir' }; } - const distKm = greatCircleKm(nadirLat, nadirLon, hitLat, hitLon); + const distKm = greatCircleKm( + { lat: nadirLat, lon: nadirLon }, + { lat: hitLat, lon: hitLon }, + ); if (distKm <= maxPullKm) { return { lat: hitLat, lon: hitLon, source: 'hit' }; } diff --git a/src/data/trafficBounds.test.mjs b/src/data/trafficBounds.test.mjs index ec68edba1..3def68111 100644 --- a/src/data/trafficBounds.test.mjs +++ b/src/data/trafficBounds.test.mjs @@ -1,8 +1,8 @@ // src/data/trafficBounds.test.mjs import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { greatCircleKm } from '../geoDistance.js'; import { - greatCircleKm, deriveFetchCenter, clampBoundsAroundCenter, } from './trafficBounds.js'; @@ -11,7 +11,7 @@ import { const NADIR = { lat: 30.2672, lon: -97.7431 }; test('greatCircleKm sanity: Austin -> ~5 km north', () => { - const d = greatCircleKm(NADIR.lat, NADIR.lon, NADIR.lat + 0.045, NADIR.lon); + const d = greatCircleKm(NADIR, { lat: NADIR.lat + 0.045, lon: NADIR.lon }); assert.ok(Math.abs(d - 5.0) < 0.1, `got ${d}`); }); @@ -41,14 +41,14 @@ test('oblique look within 12 km: uses the hit point verbatim', () => { test('horizon gaze: far hit is pulled back to 12 km along the bearing', () => { // Hit ~100 km due east of nadir. const hit = { lat: NADIR.lat, lon: NADIR.lon + 1.041 }; - assert.ok(greatCircleKm(NADIR.lat, NADIR.lon, hit.lat, hit.lon) > 90, 'precondition: far hit'); + assert.ok(greatCircleKm(NADIR, hit) > 90, 'precondition: far hit'); const c = deriveFetchCenter({ nadirLat: NADIR.lat, nadirLon: NADIR.lon, hitLat: hit.lat, hitLon: hit.lon, maxPullKm: 12, }); assert.equal(c.source, 'pulled'); - const d = greatCircleKm(NADIR.lat, NADIR.lon, c.lat, c.lon); + const d = greatCircleKm(NADIR, c); assert.ok(Math.abs(d - 12) < 0.05, `pulled distance ${d} km, expected ~12`); // Due-east bearing: latitude stays ~constant, longitude moves east but well // short of the hit. @@ -64,12 +64,12 @@ test('pull cap is honored for other maxPullKm values', () => { maxPullKm: 5, }); assert.equal(c.source, 'pulled'); - const d = greatCircleKm(NADIR.lat, NADIR.lon, c.lat, c.lon); + const d = greatCircleKm(NADIR, c); assert.ok(Math.abs(d - 5) < 0.05, `pulled distance ${d} km, expected ~5`); }); -test('pickEllipsoid failure (non-finite hit): falls back to the camera nadir', () => { - for (const [hitLat, hitLon] of [[NaN, NaN], [undefined, undefined], [30.3, undefined]]) { +test('invalid ground hit falls back to the camera nadir', () => { + for (const [hitLat, hitLon] of [[NaN, NaN], [undefined, undefined], [30.3, undefined], [91, -97], [30, 181]]) { const c = deriveFetchCenter({ nadirLat: NADIR.lat, nadirLon: NADIR.lon, hitLat, hitLon, diff --git a/src/data/transitFeeds.js b/src/data/transitFeeds.js index 2858efa62..4ef87971f 100644 --- a/src/data/transitFeeds.js +++ b/src/data/transitFeeds.js @@ -1,3 +1,5 @@ +import { greatCircleKm } from '../geoDistance.js'; + /** * @module transitFeeds * @description Registry of keyless, openly licensed GTFS-Realtime @@ -316,7 +318,8 @@ export function getRegisteredTransitFeed(id) { } /** - * Great-circle distance in km. + * Great-circle distance in km. Retained for compatibility with existing + * transit consumers; new shared spatial code should use `greatCircleKm`. * @param {number} aLat * @param {number} aLon * @param {number} bLat @@ -324,13 +327,7 @@ export function getRegisteredTransitFeed(id) { * @returns {number} */ export function haversineKm(aLat, aLon, bLat, bLon) { - const toRad = (deg) => (deg * Math.PI) / 180; - const dLat = toRad(bLat - aLat); - const dLon = toRad(bLon - aLon); - const h = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2; - return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(h))); + return greatCircleKm({ lat: aLat, lon: aLon }, { lat: bLat, lon: bLon }); } /** diff --git a/src/geoDistance.js b/src/geoDistance.js new file mode 100644 index 000000000..e71b72bb3 --- /dev/null +++ b/src/geoDistance.js @@ -0,0 +1,70 @@ +/** + * Canonical geographic point used by shared spatial utilities. + * + * @typedef {{lat: number, lon: number}} GeoPoint + */ + +/** IUGG mean spherical Earth radius, in kilometres. */ +export const EARTH_RADIUS_KM = 6371.0088; + +const DEGREES_TO_RADIANS = Math.PI / 180; + +/** + * Whether a value is a canonical, finite WGS84-style point in degrees. + * Longitudes are not implicitly wrapped because an out-of-range value usually + * indicates a broken coordinate contract at the caller. + * + * @param {*} point + * @returns {point is GeoPoint} + */ +export function isGeoPoint(point) { + return ( + Number.isFinite(point?.lat) && + point.lat >= -90 && + point.lat <= 90 && + Number.isFinite(point?.lon) && + point.lon >= -180 && + point.lon <= 180 + ); +} + +/** + * Great-circle distance in kilometres between two `{ lat, lon }` points. + * + * `{ lat, lon }` is the canonical shared coordinate representation. Invalid, + * missing, non-finite, or out-of-range coordinates return `Infinity`, which + * keeps invalid points out of threshold and nearest-neighbour selections. + * + * @param {GeoPoint} from + * @param {GeoPoint} to + * @returns {number} + */ +export function greatCircleKm(from, to) { + if (!isGeoPoint(from) || !isGeoPoint(to)) return Number.POSITIVE_INFINITY; + + const latitude1 = from.lat * DEGREES_TO_RADIANS; + const latitude2 = to.lat * DEGREES_TO_RADIANS; + const latitudeDelta = (to.lat - from.lat) * DEGREES_TO_RADIANS; + const longitudeDelta = (to.lon - from.lon) * DEGREES_TO_RADIANS; + const haversine = + Math.sin(latitudeDelta / 2) ** 2 + + Math.cos(latitude1) * + Math.cos(latitude2) * + Math.sin(longitudeDelta / 2) ** 2; + const bounded = Math.min(1, Math.max(0, haversine)); + return ( + 2 * EARTH_RADIUS_KM * Math.atan2(Math.sqrt(bounded), Math.sqrt(1 - bounded)) + ); +} + +/** + * Great-circle distance in metres, with the same point and invalid-input + * contract as {@link greatCircleKm}. + * + * @param {GeoPoint} from + * @param {GeoPoint} to + * @returns {number} + */ +export function greatCircleMeters(from, to) { + return greatCircleKm(from, to) * 1000; +} diff --git a/src/geoDistance.test.mjs b/src/geoDistance.test.mjs new file mode 100644 index 000000000..67d6c32fc --- /dev/null +++ b/src/geoDistance.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + EARTH_RADIUS_KM, + greatCircleKm, + greatCircleMeters, + isGeoPoint, +} from './geoDistance.js'; + +const AUSTIN = Object.freeze({ lat: 30.2672, lon: -97.7431 }); +const HOUSTON = Object.freeze({ lat: 29.7604, lon: -95.3698 }); + +test('great-circle identity and symmetry', () => { + assert.equal(greatCircleKm(AUSTIN, AUSTIN), 0); + assert.equal(greatCircleKm(AUSTIN, HOUSTON), greatCircleKm(HOUSTON, AUSTIN)); +}); + +test('great-circle one-degree calibration and canonical units', () => { + const kilometres = greatCircleKm({ lat: 0, lon: 0 }, { lat: 1, lon: 0 }); + assert.ok(Math.abs(kilometres - 111.195) < 0.001); + assert.equal( + greatCircleMeters({ lat: 0, lon: 0 }, { lat: 1, lon: 0 }), + kilometres * 1000, + ); +}); + +test('great-circle handles dateline crossing and antipodal points', () => { + const acrossDateline = greatCircleKm( + { lat: 0, lon: 179 }, + { lat: 0, lon: -179 }, + ); + assert.ok(Math.abs(acrossDateline - 222.39) < 0.01); + assert.ok( + Math.abs( + greatCircleKm({ lat: 0, lon: 0 }, { lat: 0, lon: 180 }) - + Math.PI * EARTH_RADIUS_KM, + ) < 1e-9, + ); +}); + +test('canonical points require explicit latitude and longitude keys', () => { + assert.equal(isGeoPoint({ lat: 10, lon: 80 }), true); + assert.equal(isGeoPoint({ latitude: 10, longitude: 80 }), false); + assert.equal(isGeoPoint([10, 80]), false); + assert.equal( + greatCircleKm( + { latitude: 10, longitude: 80 }, + { latitude: 20, longitude: 70 }, + ), + Infinity, + ); +}); + +test('latitude-before-longitude regression rejects a swapped Austin point', () => { + assert.ok(Number.isFinite(greatCircleKm(AUSTIN, HOUSTON))); + assert.equal( + greatCircleKm( + { lat: AUSTIN.lon, lon: AUSTIN.lat }, + { lat: HOUSTON.lon, lon: HOUSTON.lat }, + ), + Infinity, + ); +}); + +test('invalid, missing, non-finite, and out-of-range points return Infinity', () => { + for (const point of [ + null, + {}, + { lat: 0 }, + { lat: NaN, lon: 0 }, + { lat: 0, lon: Infinity }, + { lat: 90.0001, lon: 0 }, + { lat: 0, lon: -180.0001 }, + ]) { + assert.equal(greatCircleKm(point, AUSTIN), Infinity); + assert.equal(greatCircleMeters(AUSTIN, point), Infinity); + } +}); diff --git a/src/hud.js b/src/hud.js index c3177d808..102cc73c2 100644 --- a/src/hud.js +++ b/src/hud.js @@ -15,6 +15,7 @@ import { applicationServices } from './services/application.js'; */ import * as Cesium from 'cesium'; +import { greatCircleKm } from './geoDistance.js'; import { forward as toMGRS } from 'mgrs'; import { CITY_POIS } from './locations.js'; import { composeLocalityTag } from './hudLocality.js'; @@ -589,13 +590,7 @@ export class IntelHUD { * @returns {number} Distance in kilometers. */ _haversineKm(lat1, lon1, lat2, lon2) { - const toRad = (deg) => Cesium.Math.toRadians(deg); - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; - return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 }); } /** diff --git a/src/layers/bikeshare/model.js b/src/layers/bikeshare/model.js index 89628f613..07a937340 100644 --- a/src/layers/bikeshare/model.js +++ b/src/layers/bikeshare/model.js @@ -1,3 +1,5 @@ +import { greatCircleKm } from '../../geoDistance.js'; + export function createModel({ state: layerState, services, parts, source }) { /** * Coerce a GBFS boolean field to a native boolean. @@ -52,15 +54,7 @@ export function createModel({ state: layerState, services, parts, source }) { */ function haversineKm(aLat, aLon, bLat, bLon) { - const toRad = (value) => (value * Math.PI) / 180; - const dLat = toRad(bLat - aLat); - const dLon = toRad(bLon - aLon); - const p1 = toRad(aLat); - const p2 = toRad(bLat); - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(p1) * Math.cos(p2) * Math.sin(dLon / 2) ** 2; - return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return greatCircleKm({ lat: aLat, lon: aLon }, { lat: bLat, lon: bLon }); } /** diff --git a/src/layers/cctv/model.js b/src/layers/cctv/model.js index 48c8cddd4..ae7a84ad9 100644 --- a/src/layers/cctv/model.js +++ b/src/layers/cctv/model.js @@ -1,4 +1,5 @@ import * as Cesium from 'cesium'; +import { greatCircleKm } from '../../geoDistance.js'; import { staticFrameRefreshMs } from '../../data/cctvLod.js'; import { frameFetchDue, cardFetchPolicy } from '../../data/cctvCards.js'; import { DEFAULT_CAMERA_CALIBRATION } from './policy.js'; @@ -298,12 +299,7 @@ export function createModel({ state: layerState, services, parts, source }) { */ function haversineKm(lat1, lon1, lat2, lon2) { - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; - return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 }); } /** diff --git a/src/locations.js b/src/locations.js index d0f90ede6..ef6c01696 100644 --- a/src/locations.js +++ b/src/locations.js @@ -1,6 +1,7 @@ import { createOverpassFeatureSource } from './sources/overpassFeatures.js'; import { applicationServices } from './services/application.js'; import * as Cesium from 'cesium'; +import { greatCircleKm } from './geoDistance.js'; import { viewportBias, placesNearViewRecovery, @@ -1103,18 +1104,6 @@ export const PLACE_ANCHOR_OFFSET_RATIO = 0.15; */ const PLACE_FALLBACK_HALF_SPAN_KM = 20; -/** Great-circle distance in km (small enough here that the spherical model is fine). */ -function greatCircleKm(lat1, lng1, lat2, lng2) { - const dLat = Cesium.Math.toRadians(lat2 - lat1); - const dLng = Cesium.Math.toRadians(lng2 - lng1); - const a = - Math.sin(dLat / 2) ** 2 + - Math.cos(Cesium.Math.toRadians(lat1)) * - Math.cos(Cesium.Math.toRadians(lat2)) * - Math.sin(dLng / 2) ** 2; - return 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(a))); -} - /** * Sanity-gate a geocode viewport before framing it. Pure — exported for unit tests. * @@ -1149,10 +1138,8 @@ export function placeFramingViewport( return viewport; const offsetKm = greatCircleKm( - anchorLat, - anchorLng, - metrics.centerLat, - metrics.centerLng, + { lat: anchorLat, lon: anchorLng }, + { lat: metrics.centerLat, lon: metrics.centerLng }, ); if (offsetKm <= metrics.spanKm * PLACE_ANCHOR_OFFSET_RATIO) return viewport; diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index d607322c8..555ecf70e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -2,6 +2,7 @@ import { readLayerLifecycleSummary } from './layerSummary.js'; export { readLayerLifecycleSummary } from './layerSummary.js'; import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; +import { greatCircleKm } from '../geoDistance.js'; import { CITY_POIS, findPoiByName, @@ -3204,8 +3205,10 @@ function centerMatchesSubject(center, subjectPosition) { const subjectLat = Cesium.Math.toDegrees(carto.latitude); const subjectLon = Cesium.Math.toDegrees(carto.longitude); return ( - haversineKm(subjectLat, subjectLon, center.lat, center.lon) <= - SUBJECT_CENTER_TOLERANCE_KM + greatCircleKm( + { lat: subjectLat, lon: subjectLon }, + { lat: center.lat, lon: center.lon }, + ) <= SUBJECT_CENTER_TOLERANCE_KM ); } @@ -3495,7 +3498,7 @@ function nearbyKnownLandmarks(latitude, longitude, cameraHeightM) { for (const [cityId, city] of Object.entries(CITY_POIS)) { for (let poiIndex = 0; poiIndex < city.pois.length; poiIndex++) { const poi = city.pois[poiIndex]; - const distanceKm = haversineKm(latitude, longitude, poi.lat, poi.lon); + const distanceKm = greatCircleKm({ lat: latitude, lon: longitude }, poi); if (distanceKm > maxDistanceKm) continue; matches.push({ name: poi.name, @@ -3510,20 +3513,6 @@ function nearbyKnownLandmarks(latitude, longitude, cameraHeightM) { return matches.sort((a, b) => a.distanceKm - b.distanceKm).slice(0, 5); } -function haversineKm(lat1, lon1, lat2, lon2) { - const toRad = (value) => Cesium.Math.toRadians(value); - const radiusKm = 6371.0088; - const dLat = toRad(lat2 - lat1); - const dLon = toRad(lon2 - lon1); - const a = - Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(toRad(lat1)) * - Math.cos(toRad(lat2)) * - Math.sin(dLon / 2) * - Math.sin(dLon / 2); - return 2 * radiusKm * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - function coarseBasemapPlace( viewScale, latitude, From be7187cea7ed771e7461eb2bc41a3310ecb9b4ff Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 21 Sep 2026 10:02:19 +0700 Subject: [PATCH 002/155] fix(server): bound the terrain-heights cache and its request rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every distinct 5-decimal point was a permanent in-memory entry, a permanent line in an uncapped `.gev-cache/terrain-heights.json`, and a live upstream call. Nothing limited the rate, and coordinates were checked for being finite rather than for being on Earth, so an out-of-range pair was still a valid cache key. Four bounds, each following a pattern this repo already uses: - WGS-84 range is enforced in `parseTerrainPoints`, at the edge, so no later stage has to. Poles and the antimeridian still parse. - The point cache holds at most 50,000 entries and evicts oldest-first, the shape `trackBackfillProxies` uses. Sized from measurement: this checkout's cache file held 6,189 entries in 1.0 MB of real use, so the ceiling is eight times that working set. Terrain does not move, so an evicted point costs one refetch and never a wrong answer. - A cache file above 24 MB is not read back, mirroring `rocketLaunchesProxy`. An unmeasurable file is not treated as an oversized one: without stat, readFile decides, exactly as before. - The route carries an always-on limiter of 90/min per client, 270 global — the `/api/overpass` band, and an order of magnitude above measured demand (a full test:track run drove 10 requests, peak 8/min). The ceilings are constructor options so they can be exercised as behaviour rather than asserted as constants; production passes none. They are destructured in the body because `proxyErrorResponses.test.mjs` slices these proxies to the first column-zero closing brace. --- CHANGELOG.md | 11 ++ docs/CURRENT-STATE.md | 11 ++ scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 4 +- server/providers/terrain.js | 93 +++++++++++- src/data/terrainHeightsProxy.js | 8 ++ src/proxyErrorResponses.test.mjs | 4 + src/terrainHeightsBounds.test.mjs | 232 ++++++++++++++++++++++++++++++ 8 files changed, 360 insertions(+), 6 deletions(-) create mode 100644 src/terrainHeightsBounds.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..f59c0c97a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +- Bound what a caller can grow through `/api/terrain/heights`. Every distinct + 5-decimal point was a permanent in-memory entry, a permanent line in an + uncapped `.gev-cache/terrain-heights.json`, and a live upstream call, with + nothing limiting the rate and only a finite-number check on the coordinates. + Coordinates outside WGS-84 range are now rejected with `400`, the point cache + holds at most 50,000 entries and evicts oldest-first, a cache file above + 24 MB is not read back, and the route is limited to 90 requests/minute per + client (270 global). Evicting a point costs one refetch, never a wrong + answer, since terrain does not move. Ordinary use is unaffected: a full + `test:track` run drives 10 requests, peaking at 8/min. + - 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..adf2e7995 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2064,6 +2064,17 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > tunings. Ambient overlap includes each sprite's own rendered extent. The > gated AIS, CCTV, and satellite passes retain an active-emphasis count so a > settled dim contact always completes its release after tracking ends. +> - **Terrain-height bounds:** because every distinct 5-decimal point is its own +> cache key, its own line in `.gev-cache/terrain-heights.json`, and its own +> upstream call, four limits bound what a caller can grow. Coordinates outside +> WGS-84 range are rejected with `400` at parse (finite is not the same as on +> Earth); the point cache holds at most 50,000 entries, evicting oldest-first +> — terrain does not move, so an evicted point costs one refetch and never a +> wrong answer; a cache file above 24 MB is not read back into memory (the +> ceiling `rocketLaunchesProxy` already applies); and the route carries an +> always-on limiter of 90 requests/minute/client, 270 global. That cap is an +> order of magnitude above measured demand — a full `npm run test:track` run +> drove 10 requests, peaking at 8/min. > - **Terrain-height resilience:** `/api/terrain/heights` caches canonical > 5-decimal points individually, reconstructs reordered/overlapping batches > in exact request order, and refreshes only missing or stale points. Upstream diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eee546dd5..ca43cbbb3 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/terrainHeightsBounds.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..d12fc42ea 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -101,7 +101,9 @@ "exports": ["./server/providers/terrain"], "modules": [ "server/providers/terrain.js", - "src/data/terrainHeightsProxy.js" + "src/data/terrainHeightsProxy.js", + "server/providers/common/rate-limit.js", + "src/sources/rateLimit.js" ], "external": [] }, diff --git a/server/providers/terrain.js b/server/providers/terrain.js index e58a63ea5..ca9442db1 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -8,6 +8,7 @@ import { terrainPointKey, validTerrainResult, } from '../../src/data/terrainHeightsProxy.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; /** * Re:Earth terrain point-height proxy: batched lon/lat → ellipsoidal height @@ -20,11 +21,27 @@ import { * Only missing/stale points go upstream; the response is rebuilt in exact * request order. Larger requests are chunked sequentially, and one failing * chunk does not discard the chunks that resolved. + * + * Bounds, because every distinct 5dp point is its own key: coordinates must be + * in WGS-84 range (rejected at parse), the point cache has an entry ceiling + * with oldest-first eviction, an over-large cache file is not read back, and + * the route carries an always-on per-client limiter. */ -export function terrainHeightsProxy() { +export function terrainHeightsProxy(options = {}) { + // Destructured in the body, not the signature: `proxyErrorResponses.test.mjs` + // extracts these proxies by slicing to the first closing brace in column + // zero, so a multi-line parameter list would cut the function in half. + // Overridable so the ceilings can be exercised as behaviour rather than + // asserted as constants; production constructs this with no arguments. + const { + cacheMaxEntries = 50_000, + cacheMaxDiskBytes = 24 * 1024 * 1024, + cachePath, + } = options; const TTL_MS = 30 * 24 * 3600_000; - const CACHE_DIR = path.join(process.cwd(), '.gev-cache'); - const CACHE_PATH = path.join(CACHE_DIR, 'terrain-heights.json'); + const CACHE_PATH = + cachePath || path.join(process.cwd(), '.gev-cache', 'terrain-heights.json'); + const CACHE_DIR = path.dirname(CACHE_PATH); // Sized against measured upstream latency, not the documented page cap. // Re:Earth serves 87-186 ms/point depending on load (observed 2026-09-12, // a 2x swing within one hour). 256 points therefore costs 22-48 s and blows @@ -34,9 +51,50 @@ export function terrainHeightsProxy() { // that range. const UPSTREAM_CHUNK = 64; const MAX_POINTS = 2000; + /** + * Entry ceiling for the point cache, and with it the file written from it. + * + * Every distinct 5dp point is a separate key, so without a ceiling the map + * — and the JSON dumped from it verbatim — grow for as long as a caller + * supplies new coordinates. Measured against real use rather than guessed: + * this checkout's `.gev-cache/terrain-heights.json` held 6,189 entries in + * 1.0 MB after ordinary sessions, i.e. ~168 bytes/entry. 50,000 entries is + * eight times that working set and ~8 MB on disk, comfortably inside the + * 24 MB ceiling `rocketLaunchesProxy` already applies to its own cache file. + */ + const CACHE_MAX_ENTRIES = cacheMaxEntries; + /** Refuse to load a cache file larger than this (mirrors rocketLaunchesProxy). */ + const CACHE_MAX_DISK_BYTES = cacheMaxDiskBytes; + /** + * Requests/min/client. Terrain is keyless and free, so this guards the point + * cache and the upstream community service rather than a bill. Sized from + * measurement: across a full `npm run test:track` run — the heaviest + * exercise this app has — the route served 10 requests, peaking at 8/min. + * 90/min is an order of magnitude above that, and the band `/api/overpass` + * and `/api/military-installations` already use. + */ + const RATE_PER_MIN = 90; + const allow = makeRateLimiter({ + windowMs: 60_000, + max: RATE_PER_MIN, + globalMax: RATE_PER_MIN * 3, + }); /** @type {Map} keyed by canonical 5dp lon/lat. */ const mem = new Map(); + + /** + * Drop the oldest entries until the map is back inside its ceiling. + * Oldest-first by fetch time, the same shape `trackBackfillProxies` uses. + * Terrain does not move, so evicting an old point costs one refetch, never + * a wrong answer. + */ + function evictOverflow() { + if (mem.size <= CACHE_MAX_ENTRIES) return; + const byAge = [...mem.entries()].sort((a, b) => a[1].at - b[1].at); + for (let i = 0; i < byAge.length && mem.size > CACHE_MAX_ENTRIES; i += 1) + mem.delete(byAge[i][0]); + } /** @type {Map>>} single-flight per missing-point subset. */ const inflight = new Map(); let diskLoaded = false; @@ -47,6 +105,21 @@ export function terrainHeightsProxy() { if (diskLoaded) return; diskLoaded = true; try { + // A cache file past the ceiling is not read back into memory: loading it + // would reinstate exactly the unbounded map the ceiling exists to stop. + // The next flush rewrites a bounded one. + // + // "Cannot measure it" is not "too large": if stat is unavailable, fall + // through and let readFile decide, so an absent file behaves exactly as + // it did before this guard existed. + let sizeBytes = null; + try { + sizeBytes = (await fsp.stat(CACHE_PATH)).size; + } catch { + sizeBytes = null; + } + if (sizeBytes !== null && sizeBytes > CACHE_MAX_DISK_BYTES) + throw new Error('cache file too large'); const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8')); const pointEntries = parsed?.version === 2 && @@ -87,6 +160,7 @@ export function terrainHeightsProxy() { } diskDirty = mem.size > 0; } + evictOverflow(); } catch { /* no disk cache yet */ } @@ -163,6 +237,14 @@ export function terrainHeightsProxy() { res.end(JSON.stringify(bodyObj)); }; try { + if (!allow(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Retry-After': '10', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } await loadDiskOnce(); const parsedUrl = new URL(req.url || '', 'http://internal'); const rawPoints = parsedUrl.searchParams.get('points'); @@ -187,7 +269,10 @@ export function terrainHeightsProxy() { fetchMissing: fetchMissingSingleFlight, ttlMs: TTL_MS, }); - if (outcome.cacheChanged) diskDirty = true; + if (outcome.cacheChanged) { + evictOverflow(); + diskDirty = true; + } if (outcome.upstreamError) { console.warn( '[terrain-heights-proxy] refresh incomplete' + diff --git a/src/data/terrainHeightsProxy.js b/src/data/terrainHeightsProxy.js index bfb058693..11b8e0037 100644 --- a/src/data/terrainHeightsProxy.js +++ b/src/data/terrainHeightsProxy.js @@ -18,6 +18,13 @@ function defaultSleep(ms) { /** * Parse the proxy's `points=lon,lat;...` query parameter. + * + * Finite is not the same as on Earth. A coordinate outside WGS-84 range can + * never resolve to a height, but it is still a distinct 5dp cache key, so + * accepting it meant an out-of-range point became a permanent cache entry and + * a live upstream call. Range is checked here, at the edge, so no later stage + * has to. + * * @param {string|null|undefined} raw * @returns {Array<[number, number]>|null} */ @@ -36,6 +43,7 @@ export function parseTerrainPoints(raw) { const lon = Number(parts[0]); const lat = Number(parts[1]); if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null; + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; points.push([lon, lat]); } return points; diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 2b45a3b9e..9cc1ff9c7 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js'; +import { makeRateLimiter, clientKey } from '../server/providers/common/rate-limit.js'; const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js'] .map(file => readFileSync(new URL(`../server/providers/${file}`, import.meta.url), 'utf8')) @@ -22,6 +23,9 @@ function fixture(name, overrides = {}, preview = false) { const logs = []; const deps = { readResponseTextCapped, coalesceProxyRequest, + // The rate-limited proxies reach for these; give them the real ones so a + // fixture request is throttled exactly as production would be. + makeRateLimiter, clientKey, path, process: { cwd: () => '/fixture', env: {} }, fsp: { readFile: async () => { throw new Error('cache absent'); }, diff --git a/src/terrainHeightsBounds.test.mjs b/src/terrainHeightsBounds.test.mjs new file mode 100644 index 000000000..1a0c80290 --- /dev/null +++ b/src/terrainHeightsBounds.test.mjs @@ -0,0 +1,232 @@ +// TERRAIN HEIGHTS — the bounds on a cache keyed by caller-chosen coordinates. +// +// Every distinct 5dp lon/lat is its own cache key, its own permanent line in +// `.gev-cache/terrain-heights.json`, and its own upstream call. Four things +// bound that: WGS-84 range at parse, an entry ceiling on the map, a size +// ceiling on the file that is read back, and a per-client request limiter. +// +// These cases drive the real exported route handler. `fetch` is stubbed per +// test and restored after, so a case that reaches upstream fails loudly. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { parseTerrainPoints } from './data/terrainHeightsProxy.js'; +import { terrainHeightsProxy } from '../server/providers/terrain.js'; + +function install(plugin) { + const routes = new Map(); + plugin.configureServer({ + middlewares: { + use(path, handler) { + routes.set(path, handler); + }, + }, + }); + return routes; +} + +function request(handler, { url = '/', remoteAddress = '127.0.0.1' } = {}) { + return new Promise((resolve, reject) => { + const headers = new Map(); + const req = { method: 'GET', url, headers: {}, socket: { remoteAddress } }; + const res = { + statusCode: 200, + headersSent: false, + setHeader(name, value) { + headers.set(String(name).toLowerCase(), String(value)); + }, + writeHead(status, hdrs = {}) { + this.statusCode = status; + this.headersSent = true; + for (const [name, value] of Object.entries(hdrs)) + headers.set(String(name).toLowerCase(), String(value)); + }, + end(body = '') { + let parsed = null; + try { + parsed = body ? JSON.parse(String(body)) : null; + } catch { + parsed = String(body); + } + resolve({ + statusCode: this.statusCode, + headers: Object.fromEntries(headers), + body: parsed, + }); + }, + }; + Promise.resolve(handler(req, res)).catch(reject); + }); +} + +function stubFetch(t, impl) { + const original = globalThis.fetch; + globalThis.fetch = impl; + t.after(() => { + globalThis.fetch = original; + }); +} + +/** + * Answer like Re:Earth does: one `{ellipsoid}` per requested point, in order. + * Records which point strings were actually asked for, so a test can assert on + * cache misses rather than on internal state. + */ +function stubTerrainUpstream(t) { + const asked = []; + stubFetch(t, async (url) => { + const points = decodeURIComponent( + new URL(String(url)).searchParams.get('points') || '', + ) + .split(';') + .filter(Boolean); + asked.push(...points); + return Response.json({ results: points.map(() => ({ ellipsoid: 150 })) }); + }); + return asked; +} + +test('a coordinate off the globe is not a cache key', () => { + // Finite but not on Earth — the old guard accepted every one of these. + for (const raw of [ + '0,91', + '0,-91', + '181,0', + '-181,0', + '1e9,1e9', + '0,90.00001', + ]) + assert.equal(parseTerrainPoints(raw), null, raw); + + // The poles and the antimeridian are real places and must still parse. + for (const raw of ['0,90', '0,-90', '180,0', '-180,0', '-97.74,30.26']) + assert.ok(parseTerrainPoints(raw), raw); +}); + +test('an out-of-range point is refused before it reaches upstream or the cache', async (t) => { + let upstreamCalls = 0; + stubFetch(t, async () => { + upstreamCalls += 1; + return new Response('[]', { status: 200 }); + }); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + + const refused = await request(route, { url: '/?points=0,91' }); + assert.equal(refused.statusCode, 400); + assert.equal( + upstreamCalls, + 0, + 'an impossible coordinate must not cost an upstream call', + ); +}); + +test('one out-of-range point rejects the batch it is hidden in', async (t) => { + stubFetch(t, async () => new Response('[]', { status: 200 })); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + const mixed = await request(route, { + url: '/?points=-97.74,30.26;0,999;-97.75,30.27', + }); + assert.equal(mixed.statusCode, 400); +}); + +test('the point cache stops growing, and an evicted point is simply refetched', async (t) => { + const asked = stubTerrainUpstream(t); + // A ceiling of 3 exercises the same code path 50,000 does, in four requests. + const route = install(terrainHeightsProxy({ cacheMaxEntries: 3 })).get( + '/api/terrain/heights', + ); + + const point = (i) => `-97.7${i}000,30.26000`; + for (let i = 1; i <= 4; i += 1) + await request(route, { url: `/?points=${point(i)}` }); + assert.equal(asked.length, 4, 'four distinct points, four upstream lookups'); + + // The newest point is still cached... + await request(route, { url: `/?points=${point(4)}` }); + assert.equal(asked.length, 4, 'a cached point costs nothing'); + + // ...and the oldest was evicted, so it costs one refetch — never a wrong + // answer, because terrain does not move. + await request(route, { url: `/?points=${point(1)}` }); + assert.equal(asked.length, 5, 'the evicted point was asked for again'); + assert.equal(asked[4], point(1)); +}); + +test('an over-large cache file is not read back into memory', async (t) => { + const asked = stubTerrainUpstream(t); + const point = '-97.74000,30.26000'; + const dir = await mkdtemp(path.join(tmpdir(), 'gev-terrain-')); + const cachePath = path.join(dir, 'terrain-heights.json'); + t.after(() => rm(dir, { recursive: true, force: true })); + + // A cache file holding the answer, but larger than the ceiling allows. + const padded = { + version: 2, + points: { [point]: { at: Date.now(), result: { ellipsoid: 150 } } }, + _pad: 'x'.repeat(4096), + }; + await writeFile(cachePath, JSON.stringify(padded), 'utf8'); + + const route = install( + terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 }), + ).get('/api/terrain/heights'); + await request(route, { url: `/?points=${point}` }); + assert.deepEqual( + asked, + [point], + 'the oversized file was skipped, so the point was a miss', + ); + + // The same file under a ceiling that admits it IS used, which is what proves + // the previous assertion came from the size guard and not a broken path. + const admitted = install( + terrainHeightsProxy({ cachePath, cacheMaxDiskBytes: 1024 * 1024 }), + ).get('/api/terrain/heights'); + await request(admitted, { url: `/?points=${point}` }); + assert.deepEqual(asked, [point], 'a cache within the ceiling is still read'); +}); + +test('the route refuses a flood of requests from one client', async (t) => { + stubTerrainUpstream(t); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + + let refusedAt = 0; + for (let i = 1; i <= 95; i += 1) { + // A different point each time: distinct keys are the growth path. + const lon = (-97.7 - i / 1000).toFixed(5); + const answer = await request(route, { url: `/?points=${lon},30.26` }); + if (answer.statusCode === 429) { + refusedAt = i; + break; + } + } + assert.equal(refusedAt, 91, 'the 91st request in a minute is refused'); +}); + +test('a second client keeps its own quota', async (t) => { + stubFetch(t, async () => + Response.json([{ lng: -97.74, lat: 30.26, height: 150 }]), + ); + const route = install(terrainHeightsProxy()).get('/api/terrain/heights'); + + for (let i = 0; i < 90; i += 1) + await request(route, { + url: `/?points=${(-98 - i / 1000).toFixed(5)},30.26`, + remoteAddress: '198.51.100.7', + }); + const exhausted = await request(route, { + url: '/?points=-99.5,30.26', + remoteAddress: '198.51.100.7', + }); + assert.equal(exhausted.statusCode, 429); + + const neighbour = await request(route, { + url: '/?points=-99.6,30.26', + remoteAddress: '198.51.100.8', + }); + assert.notEqual(neighbour.statusCode, 429); +}); From 936d75b69e89b2d5005db060a06f8d2f18d3ef01 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Wed, 23 Sep 2026 16:45:08 +0700 Subject: [PATCH 003/155] feat(flights): publish verticalRateMps on the tracked-aircraft descriptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mapAnalystRecord` has always published `verticalRateMps`, but the tracked seam — `getTrackedInfo()` / `getTrackedSubject()`, built by `_describeFlight` — did not, so anything needing climb/descent had to reach past the public descriptor into the raw poll record. Both layers now map it, which matters because they store it under different keys: the civil records write `verticalRate` (records.js), the military ones write `verticalRateMps`. Mapping only one would leave `resolveTrackedAircraftInfo()` handing back two different shapes, and a consumer reading the field off a civil contact would silently read every military contact as level flight. Reported, never derived. `stickyNumber` seeds the field with null, so an aircraft that has not transmitted a rate publishes null rather than a level-flight 0 — absent is not level, and a flight-phase classifier that cannot tell them apart calls a climbing aircraft cruise. Tests: both layers, both halves (a reported rate arrives with its sign; an absent one stays null). Mutation-verified — removing either field or defaulting it to 0 turns them red. `docs/CURRENT-STATE.md` records the descriptor field, the two record keys it reconciles, and the reported-never-derived rule. --- CHANGELOG.md | 5 ++++ docs/CURRENT-STATE.md | 7 +++++ src/data/flights.test.mjs | 49 ++++++++++++++++++++++++++++++- src/data/militaryFlights.test.mjs | 45 ++++++++++++++++++++++++++++ src/layers/flights/queries.js | 13 ++++++-- src/layers/military/queries.js | 11 +++++-- 6 files changed, 125 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..ae79c5dc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,11 @@ Native point, billboard and label highlighting uses GPU draw commands; there is no scene-dimming effect selector. Unsupported shaders retain native contact rendering, and leaving Cyber restores the standard shell and contact treatment. +- The tracked-aircraft descriptor now publishes `verticalRateMps`, on both the + civil and military layers, so a consumer reading `getTrackedInfo()` no longer + has to reach past it into the raw poll record for climb/descent. Reported, + never derived: an aircraft that has not transmitted a rate reads as `null` + rather than a level-flight `0`. - Add a **Recent Imagery** data layer (NASA GIBS · HLS + VIIRS, keyless). Select a box (drag, the current view, or around a pin; up to 1,000 km a diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..87db7a7ac 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -567,6 +567,13 @@ use the supplied asset resolver, including the preload path. Enrichment receives an abort signal and cannot update a later lifecycle after destruction. Existing camera, terrain floor, trail, selection and measured model-size policies remain. +The tracked-aircraft descriptor both flight layers hand to the cockpit carries +`verticalRateMps` beside `velocityMps` and `track`. Civil records store the rate as +`verticalRate` and military records as `verticalRateMps`; the query seam maps both to +one name, so `resolveTrackedAircraftInfo` returns a single shape for either layer. The +rate is reported, never derived: a blank poll keeps the last transmitted value and an +absent rate stays null rather than reading as level flight. + ## Browser live-source observations Flights, Military Flights and AIS Vessels obtain snapshots and optional history diff --git a/src/data/flights.test.mjs b/src/data/flights.test.mjs index 2834e8833..48fb05737 100644 --- a/src/data/flights.test.mjs +++ b/src/data/flights.test.mjs @@ -487,7 +487,15 @@ const LABEL_ICAO = 'ae1fa4'; const LABEL_POSITION = Cesium.Cartesian3.fromDegrees(-97.71, 30.21, 10_668); /** Seed one civil contact through the production refresh seam. */ -function seedLabelContact({ callsign, registration, tracked = false }) { +function seedLabelContact({ + callsign, + registration, + tracked = false, + // Per-test metadata overrides, spread last so a test can set a field to + // `undefined` to model "the feed never sent it" — a default parameter + // could not express that, it would just re-supply the default. + meta: metaOverrides = {}, +}) { _setTrackedFlightRefreshStateForTest({ icao24: LABEL_ICAO, entity: null, @@ -509,6 +517,7 @@ function seedLabelContact({ callsign, registration, tracked = false }) { turnRateDps: 0, rawLat: 30.21, rawLon: -97.71, + ...metaOverrides, }, }); } @@ -574,6 +583,44 @@ test('civil label chain: the cockpit descriptor exposes a trimmed registration', assert.equal(flightsLayer.getTrackedInfo()?.registration, null); }); +test('the tracked descriptor publishes the reported vertical rate', () => { + // `mapAnalystRecord` has always published `verticalRateMps`; the tracked + // seam did not, so a consumer that needs climb/descent (a flight-phase + // classifier, the cockpit) had to reach past getTrackedInfo() into the + // raw record. Both seams now answer with the same field name and unit. + seedLabelContact({ + callsign: 'SWA696', + registration: 'N123AB', + tracked: true, + meta: { verticalRate: -6.4 }, + }); + assert.equal(flightsLayer.getTrackedInfo()?.verticalRateMps, -6.4); + assert.equal( + mapAnalystRecord(LABEL_ICAO, { verticalRate: -6.4 }).verticalRateMps, + -6.4, + 'the analyst seam and the tracked seam must agree', + ); +}); + +test('an unreported vertical rate stays null, never a level-flight zero', () => { + // Absent is not level. `stickyNumber` seeds this field with null, so a + // contact that has never transmitted a rate must not read as 0 m/s — a + // phase classifier would call that cruise while the aircraft climbs. + for (const missing of [undefined, null, Number.NaN]) { + seedLabelContact({ + callsign: 'SWA696', + registration: 'N123AB', + tracked: true, + meta: { verticalRate: missing }, + }); + assert.equal( + flightsLayer.getTrackedInfo()?.verticalRateMps, + null, + `vertical rate ${String(missing)} must publish as null`, + ); + } +}); + test('civil label chain: identity stays icao24 while the label moves', () => { for (const fixture of [ { callsign: 'SWA696', registration: 'N123AB' }, diff --git a/src/data/militaryFlights.test.mjs b/src/data/militaryFlights.test.mjs index 122b6b132..faf9722fc 100644 --- a/src/data/militaryFlights.test.mjs +++ b/src/data/militaryFlights.test.mjs @@ -603,3 +603,48 @@ test('two contacts matching at the same strength resolve deterministically', () assert.equal(first, 'aef001', 'the stable key (lowest hex) wins, not the feed order'); assert.equal(second, first, 'and the same query resolves the same way every time'); }); + +test('the military tracked descriptor publishes the reported vertical rate', () => { + // Shape parity with the civil layer's getTrackedInfo(). The two layers store + // the rate under different record keys (`verticalRateMps` here, + // `verticalRate` there), so `resolveTrackedAircraftInfo` only hands back one + // shape if BOTH descriptors map it — a consumer that reads the field off the + // civil seam and finds it missing on the military one would silently treat + // every military contact as level flight. + const seedRate = (verticalRateMps) => + _setTrackedMilitaryRefreshStateForTest({ + icao24: 'ae7f01', + entity: { gevLabelModel: { title: 'TEST', details: [] } }, + billboard: { + position: Cesium.Cartesian3.fromDegrees(-97.0, 31.0, 8_000), + color: Cesium.Color.WHITE, + show: true, + }, + billboardCollection: { show: true, remove() {} }, + viewer: { camera: { positionCartographic: null }, scene: {} }, + meta: { + callsign: 'RCH512', + rawLat: 31, + rawLon: -97, + altitudeFt: 26_000, + speedMps: 220, + track: 95, + onGround: false, + verticalRateMps, + }, + }); + + seedRate(12.7); + assert.equal(militaryFlightsLayer.getTrackedInfo()?.verticalRateMps, 12.7); + + // Absent is not level: a contact that has never transmitted a rate must read + // as null, not as a 0 m/s a phase classifier would call cruise. + for (const missing of [undefined, null, Number.NaN]) { + seedRate(missing); + assert.equal( + militaryFlightsLayer.getTrackedInfo()?.verticalRateMps, + null, + `vertical rate ${String(missing)} must publish as null`, + ); + } +}); diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 5b64b2247..423f43788 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -113,7 +113,7 @@ export function createQueries({ * Build a public descriptor for one aircraft using its best current position * (dead-reckoned when history exists, billboard position otherwise). * @param {string} icao24 - ICAO 24-bit transponder address. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Descriptor with a cloned position, or null if the aircraft is unknown. */ @@ -163,6 +163,15 @@ export function createQueries({ onGround: info?.onGround === true, velocityMps: displayed.speedMps, track: displayed.trackDeg, + // Climb/descent rate, the one kinematic field `mapAnalystRecord` already + // published (as `verticalRateMps`) but this descriptor did not, so a + // consumer reading the tracked seam had to reach back into `_flightData` + // for it. Reported, never derived: `stickyNumber` in records.js keeps the + // last value across a blank poll, and an absent rate stays null rather + // than becoming a level-flight 0 the aircraft never transmitted. + verticalRateMps: Number.isFinite(info?.verticalRate) + ? info.verticalRate + : null, stale: Boolean( flightState.records.missingPolls.get(icao24) || flightState.feed._backoff, @@ -834,7 +843,7 @@ export function createQueries({ /** * Describe the currently tracked aircraft at its dead-reckoned position. - * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Tracked aircraft info, or null when nothing is tracked. */ getTrackedInfo() { diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 421e9804d..9553cf181 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -86,7 +86,7 @@ export function createQueries({ * Build a public descriptor for one aircraft using its best current position * (dead-reckoned when history exists, billboard position otherwise). * @param {string} icao24 - ICAO hex identifier of the aircraft. - * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Descriptor with a cloned position, or null if the aircraft is unknown. */ @@ -129,6 +129,13 @@ export function createQueries({ onGround: info?.onGround === true, velocityMps: displayed.speedMps, track: displayed.trackDeg, + // Same field, same name, same null-when-absent rule as the civil + // descriptor — the two layers store the record under different keys + // (`verticalRateMps` here, `verticalRate` there), so only the mapping + // makes `resolveTrackedAircraftInfo` hand back one shape for both. + verticalRateMps: Number.isFinite(info?.verticalRateMps) + ? info.verticalRateMps + : null, stale: Boolean( flightState.records.missingPolls.get(icao24) || flightState.feed._backoff, @@ -755,7 +762,7 @@ export function createQueries({ /** * Describe the currently tracked aircraft at its dead-reckoned position. - * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null}|null} + * @returns {{icao24: string, callsign: string|null, latitude: number, longitude: number, altitudeM: number, velocityMps: number|null, track: number|null, verticalRateMps: number|null}|null} * Tracked aircraft info, or null when nothing is tracked. */ getTrackedInfo() { From 114b0eeaba1d3cb9cb142e4521c79b396d3e439d Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 08:14:11 +0700 Subject: [PATCH 004/155] feat(tooling): one changelog file per pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CONTRIBUTING.md` asks every runtime change to add an entry at the top of `CHANGELOG.md`, so two pull requests in flight write into the same few lines and git has nothing to merge on. Against `main` at `81eb443`, `git merge-tree` over the 85 open pull requests GitHub reports as conflicting that aren't mine puts `CHANGELOG.md` in the conflict set of 41 — more than any other path — and for 6 it is the only one. `docs/CURRENT-STATE.md`, the other file rule 3 names, conflicts for 23, and merges cleanly for 23 of the 41: edits land in the section for the area you touched, so they only collide when the areas do. A pull request now adds `changelog.d/-.md` instead. Distinct filenames never conflict, and the pull request number makes them distinct for free. `npm run changelog` splices the directory into `CHANGELOG.md` and empties it; `npm run changelog:check` reports the batch and changes nothing. Fragment text is copied through unchanged apart from trailing whitespace. `src/annotations/drawTool.test.mjs` reads `CHANGELOG.md` as prose and slices a fixed window after a heading, so an assembler that reflowed an entry could move text across that window. Fragments are deliberately outside `scripts/format-scope.json` for the same reason. The assembler refuses a batch before writing anything when a filename is not `-.md` or two fragments claim one pull request number, and a unit test runs that check over the real `changelog.d/`, so a fragment the release could not assemble fails `npm test` in the pull request that adds it rather than at release time. Refs #781. --- CONTRIBUTING.md | 2 +- changelog.d/README.md | 45 ++++++ docs/MAINTAINER_WORKFLOW.md | 11 +- package.json | 2 + scripts/assemble-changelog.mjs | 120 ++++++++++++++++ scripts/format-scope.json | 4 +- src/tooling/changelogFragments.test.mjs | 178 ++++++++++++++++++++++++ 7 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 changelog.d/README.md create mode 100644 scripts/assemble-changelog.mjs create mode 100644 src/tooling/changelogFragments.test.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cdfbb6547..56775e824 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -149,7 +149,7 @@ ownership and adoption process. 1. Branch off `main`. 2. Keep `npm run build`, `npm test`, and `npm run test:track` green and avoid new console errors, plus the [feature gate](#feature-regression-gates) for the area you touched. -3. If you change runtime behavior, update `docs/CURRENT-STATE.md` and `CHANGELOG.md` in the same PR. +3. If you change runtime behavior, update `docs/CURRENT-STATE.md` in the same PR, and add your changelog entry as `changelog.d/-.md` instead of editing `CHANGELOG.md` — see [changelog.d/README.md](changelog.d/README.md). Distinct filenames don't conflict; a release assembles the directory into `CHANGELOG.md`. A documentation claim that needs a regression test should assert against `docs/CURRENT-STATE.md` or `README.md`, which a PR still edits directly. 4. If you add or change a data source, update [DATA_SOURCES.md](DATA_SOURCES.md) with its license and attribution. **Don't add data you don't have the right to redistribute** — fetch it at runtime instead. 5. Describe what you changed and how you verified it (screenshots welcome for anything visual). diff --git a/changelog.d/README.md b/changelog.d/README.md new file mode 100644 index 000000000..26a7c2e07 --- /dev/null +++ b/changelog.d/README.md @@ -0,0 +1,45 @@ +# Changelog fragments + +One file per entry. A pull request adds a file here instead of editing +`CHANGELOG.md`, and a release concatenates them into it. + +``` +changelog.d/-.md +``` + +`723-atc-decision-layer.md`, for example. The slug is lowercase, words separated +by `-`. Two pull requests never pick the same filename, because the pull request +number is already unique — which is the point: new `CHANGELOG.md` entries go at +the top of the file, so two pull requests in flight write into the same few lines +and git has nothing to merge on. + +Write the file exactly as the entry should read in `CHANGELOG.md`: a `-` bullet, +wrapped the way the surrounding entries are, crediting yourself and the pull +request. + +```markdown +- Report which upstream declined a Street Traffic road load. The layer row now + reads `Overpass rate-limited` instead of a general "Road data temporarily + unavailable" (yourname, #665). +``` + +The text is spliced in unchanged apart from trailing whitespace, so what you +write here is what ships. Fragments are deliberately outside +`scripts/format-scope.json`, so `npm run format` never reflows one. + +At release time: + +``` +npm run changelog:check # list what would move, change nothing +npm run changelog # move every fragment into CHANGELOG.md +``` + +The assembler refuses a batch rather than guessing: a filename that is not +`-.md`, or two fragments claiming one pull request number, +stops the run before anything is written and names the files. + +A documentation claim that needs a regression test — the way +`src/annotations/drawTool.test.mjs` asserts against `CHANGELOG.md` — should +assert against `docs/CURRENT-STATE.md` or `README.md` instead. A pull request +still edits those directly, so the text is in the file while the change is under +review; a fragment does not reach `CHANGELOG.md` until release. diff --git a/docs/MAINTAINER_WORKFLOW.md b/docs/MAINTAINER_WORKFLOW.md index 39c87be4b..632400c9c 100644 --- a/docs/MAINTAINER_WORKFLOW.md +++ b/docs/MAINTAINER_WORKFLOW.md @@ -57,7 +57,8 @@ with a reason; usefulness can also be `decline` or `discuss`. - Inspect removed tests, skipped tests, relaxed assertions, snapshot updates, mocks, and test-runner changes. A green result from weakened tests is not acceptance. - Check applicable documentation updates: runtime changes need `CURRENT-STATE.md` - and `CHANGELOG.md`; source changes need `DATA_SOURCES.md`. + and a `changelog.d/-.md` fragment rather than an edit to + `CHANGELOG.md`; source changes need `DATA_SOURCES.md`. ### 3. Security and supply chain @@ -173,6 +174,14 @@ Before merging: a stale merge. Verify the merged result matches the validated candidate tree and record the resulting commit and PR URL. Stop and report any mismatch. +Pending release notes live in `changelog.d/` as one file per pull request, so an +integration never has to resolve the top of `CHANGELOG.md`. When cutting a release, +run `npm run changelog:check` to see the batch and the order it would take, then +`npm run changelog` to splice it into `CHANGELOG.md` and empty the directory. The +assembler refuses a malformed filename or two fragments claiming one pull request +number before it writes anything, and copies the text through unchanged, so the +contributor's own wording and credit are what ship. + Repository rules enforce only their configured requirements; this document and the skill do not configure branch protection or CI. Maintainers should require CI and review on `main` and dismiss stale approvals when changes are pushed. diff --git a/package.json b/package.json index e48881c6d..4fd753168 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "format:check": "node scripts/format.mjs --check", "layer-token:next": "node scripts/next-layer-state-token.mjs", "layer-token:check": "node scripts/check-layer-state-tokens.mjs", + "changelog": "node scripts/assemble-changelog.mjs --write", + "changelog:check": "node scripts/assemble-changelog.mjs --check", "dev": "vite", "dev:secure": "./scripts/dev-secure.sh", "opensky:import": "./scripts/opensky-import-client.sh", diff --git a/scripts/assemble-changelog.mjs b/scripts/assemble-changelog.mjs new file mode 100644 index 000000000..8f3e854ef --- /dev/null +++ b/scripts/assemble-changelog.mjs @@ -0,0 +1,120 @@ +import { readdir, readFile, writeFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * Release-time assembly of changelog fragments. + * + * A pull request adds `changelog.d/-.md` instead of editing + * `CHANGELOG.md`. Distinct filenames never conflict, so two pull requests in + * flight no longer write into the same few lines at the top of one file. A + * release splices the fragments in and empties the directory. + * + * Fragment text is copied through unchanged apart from trailing whitespace: + * `src/annotations/drawTool.test.mjs` reads `CHANGELOG.md` as prose and slices a + * fixed window after a heading, so reflowing an entry here could move text + * across that window. + */ +export const FRAGMENT_DIR = 'changelog.d'; +const FRAGMENT_NAME = /^(\d+)-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/; +const HEADING = '# Changelog\n'; + +/** Fragment file names a release may consume, highest pull request first. */ +export function orderFragmentNames(names) { + const claimed = new Map(); + for (const name of names) { + if (name === 'README.md') continue; + const match = FRAGMENT_NAME.exec(name); + if (!match) { + throw new Error( + `Unexpected file in ${FRAGMENT_DIR}/: ${name}` + + ' — expected -.md with a lowercase slug', + ); + } + const number = Number(match[1]); + const prior = claimed.get(number); + if (prior) { + throw new Error( + `Two fragments claim pull request ${number}: ${prior} and ${name}`, + ); + } + claimed.set(number, name); + } + return [...claimed.entries()] + .sort(([a], [b]) => b - a) + .map(([, name]) => name); +} + +/** Splice fragment bodies above the existing entries, newest batch on top. */ +export function assembleChangelog(changelog, fragments) { + if (!fragments.length) return changelog; + if (!changelog.startsWith(HEADING)) { + throw new Error('CHANGELOG.md must start with "# Changelog"'); + } + const entries = changelog.slice(HEADING.length).replace(/^\n+/, ''); + const batch = fragments.map((text) => text.replace(/\s+$/, '')).join('\n\n'); + return `${HEADING}\n${batch}\n\n${entries}`; +} + +/** + * Report or apply the pending fragments in `/changelog.d`. + * + * The batch is validated in full before anything is written, so a malformed + * filename or a duplicate pull request number leaves the directory untouched. + * + * @param {string} root repository root + * @param {'--check'|'--write'} mode `--check` reports and changes nothing + * @returns {Promise<{ consumed: string[], changelog: string }>} + */ +export async function assembleFragments(root, mode) { + if (!['--check', '--write'].includes(mode)) { + throw new Error( + 'Usage: node scripts/assemble-changelog.mjs --check|--write', + ); + } + const directory = path.join(root, FRAGMENT_DIR); + const changelogFile = path.join(root, 'CHANGELOG.md'); + const consumed = orderFragmentNames(await readdir(directory)); + const bodies = []; + for (const name of consumed) { + bodies.push(await readFile(path.join(directory, name), 'utf8')); + } + const changelog = assembleChangelog( + await readFile(changelogFile, 'utf8'), + bodies, + ); + if (mode === '--write' && consumed.length) { + await writeFile(changelogFile, changelog, 'utf8'); + for (const name of consumed) await rm(path.join(directory, name)); + } + return { consumed, changelog }; +} + +const invoked = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href + : ''; +if (import.meta.url === invoked) { + try { + if (process.argv.length !== 3) { + throw new Error( + 'Usage: node scripts/assemble-changelog.mjs --check|--write', + ); + } + const mode = process.argv[2]; + const { consumed } = await assembleFragments( + fileURLToPath(new URL('../', import.meta.url)), + mode, + ); + if (!consumed.length) { + console.log(`No fragments in ${FRAGMENT_DIR}/.`); + } else { + console.log( + `${mode === '--write' ? 'Assembled' : 'Would assemble'} ${consumed.length} fragment(s), in order:` + + `\n ${consumed.join('\n ')}`, + ); + } + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ef44df03f..974275366 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -844,5 +844,7 @@ "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", + "scripts/assemble-changelog.mjs", + "src/tooling/changelogFragments.test.mjs" ] diff --git a/src/tooling/changelogFragments.test.mjs b/src/tooling/changelogFragments.test.mjs new file mode 100644 index 000000000..880e3318d --- /dev/null +++ b/src/tooling/changelogFragments.test.mjs @@ -0,0 +1,178 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + mkdtemp, + mkdir, + writeFile, + readFile, + readdir, + rm, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + FRAGMENT_DIR, + assembleChangelog, + assembleFragments, + orderFragmentNames, +} from '../../scripts/assemble-changelog.mjs'; + +const EXISTING = + '# Changelog\n\n- An entry that was already released (someone, #1).\n'; + +const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url)); + +async function fixture(t, fragments = {}) { + const root = await mkdtemp(path.join(tmpdir(), 'gev-changelog-')); + t.after(() => rm(root, { recursive: true, force: true })); + await mkdir(path.join(root, FRAGMENT_DIR)); + await writeFile( + path.join(root, FRAGMENT_DIR, 'README.md'), + '# How to add one\n', + ); + await writeFile(path.join(root, 'CHANGELOG.md'), EXISTING); + for (const [name, body] of Object.entries(fragments)) { + await writeFile(path.join(root, FRAGMENT_DIR, name), body); + } + return root; +} + +test('the batch reads newest pull request first', () => { + assert.deepEqual(orderFragmentNames(['7-b.md', '112-c.md', '19-a.md']), [ + '112-c.md', + '19-a.md', + '7-b.md', + ]); +}); + +test('the directory README is documentation, never an entry', () => { + assert.deepEqual(orderFragmentNames(['README.md', '5-a.md']), ['5-a.md']); +}); + +test('two fragments claiming one pull request fail loudly, not silently', () => { + assert.throws( + () => orderFragmentNames(['5-first.md', '5-second.md']), + /Two fragments claim pull request 5/, + ); +}); + +test('a name that is not -.md is refused', () => { + for (const name of ['notes.md', '5_underscore.md', '5-Caps.md', '5-a.txt']) { + assert.throws(() => orderFragmentNames([name]), /Unexpected file/, name); + } +}); + +test('fragment text is spliced in unchanged, above the existing entries', () => { + // Wrapped, indented and punctuated the way a real entry is: the continuation + // indent and the blank line inside the entry are the parts a reflow would eat. + const entry = [ + '- A new thing, with `code` and a — dash. The second line is indented the', + ' way the surrounding entries wrap, and it keeps its two spaces.', + '', + ' A second paragraph, because some entries have one (someone, #9).', + ].join('\n'); + const result = assembleChangelog(EXISTING, [`${entry}\n\n\n`]); + assert.equal( + result, + `# Changelog\n\n${entry}\n\n- An entry that was already released (someone, #1).\n`, + ); + // The entry survives byte for byte: drawTool.test.mjs reads this file as + // prose and slices a fixed window, so reflowing would move text across it. + assert.ok(result.includes(entry)); +}); + +test('a CHANGELOG without the heading is refused rather than prepended to', () => { + assert.throws( + () => assembleChangelog('- orphan entry\n', ['- new\n']), + /must start with "# Changelog"/, + ); +}); + +test('assembling consumes the fragments and leaves the README', async (t) => { + const root = await fixture(t, { + '20-second.md': '- Second (someone, #20).', + '100-first.md': '- First (someone, #100).', + }); + const { consumed } = await assembleFragments(root, '--write'); + assert.deepEqual(consumed, ['100-first.md', '20-second.md']); + const changelog = await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'); + assert.match( + changelog, + /# Changelog\n\n- First \(someone, #100\)\.\n\n- Second \(someone, #20\)\.\n\n- An entry that was already released/, + ); + assert.deepEqual(await readdir(path.join(root, FRAGMENT_DIR)), ['README.md']); +}); + +test('a rejected batch leaves the directory and the file untouched', async (t) => { + const root = await fixture(t, { + '8-first.md': '- First (someone, #8).', + '8-again.md': '- Again (someone, #8).', + }); + await assert.rejects( + assembleFragments(root, '--write'), + /Two fragments claim pull request 8/, + ); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); + assert.deepEqual((await readdir(path.join(root, FRAGMENT_DIR))).sort(), [ + '8-again.md', + '8-first.md', + 'README.md', + ]); +}); + +test('check mode changes nothing on disk', async (t) => { + const root = await fixture(t, { '3-a.md': '- Something (someone, #3).' }); + const { consumed } = await assembleFragments(root, '--check'); + assert.deepEqual(consumed, ['3-a.md']); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); + assert.deepEqual((await readdir(path.join(root, FRAGMENT_DIR))).sort(), [ + '3-a.md', + 'README.md', + ]); +}); + +test('an empty directory is a no-op, so a release can always run it', async (t) => { + const root = await fixture(t); + const { consumed } = await assembleFragments(root, '--write'); + assert.deepEqual(consumed, []); + assert.equal( + await readFile(path.join(root, 'CHANGELOG.md'), 'utf8'), + EXISTING, + ); +}); + +test('a mode that is neither check nor write is refused', async (t) => { + const root = await fixture(t); + await assert.rejects(assembleFragments(root, '--force'), /--check\|--write/); +}); + +// The two below run against the repository itself, so a fragment that the +// release could not assemble fails `npm test` in the pull request that adds it +// rather than at release time, when its author has moved on. + +test('every pending fragment in the repository can be assembled', async () => { + const names = await readdir(path.join(repositoryRoot, FRAGMENT_DIR)); + assert.ok( + names.includes('README.md'), + `${FRAGMENT_DIR}/README.md documents the convention and keeps the directory tracked`, + ); + // Throws on a name that is not -.md, and on two fragments + // claiming one pull request number. Every other file is an entry. + assert.equal(orderFragmentNames(names).length, names.length - 1); +}); + +test('CHANGELOG.md still carries the heading a release splices under', async () => { + const changelog = await readFile( + path.join(repositoryRoot, 'CHANGELOG.md'), + 'utf8', + ); + assert.ok(changelog.startsWith('# Changelog\n')); +}); From d29ff40d04a1ce7de8872488159e38e39f570339 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 28 Sep 2026 08:17:18 +0700 Subject: [PATCH 005/155] docs: add this pull request's own changelog fragment (#787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfoods the convention rather than describing it: the entry for #787 arrives as `changelog.d/787-changelog-fragments.md` and `CHANGELOG.md` is untouched. A fragment needs the pull request number, which only exists once the pull request is open, so it lands in a second commit. `changelog.d/README.md` says to push first and add the fragment after, for the same reason. Checked against the one consumer that constrains the design: assembling this fragment moves the `DISPLAY ▸ Draw` marker in `CHANGELOG.md` by exactly the length of the spliced entry, and the 1200-character window that `src/annotations/drawTool.test.mjs` slices after it is byte-identical. --- changelog.d/787-changelog-fragments.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/787-changelog-fragments.md diff --git a/changelog.d/787-changelog-fragments.md b/changelog.d/787-changelog-fragments.md new file mode 100644 index 000000000..41509c046 --- /dev/null +++ b/changelog.d/787-changelog-fragments.md @@ -0,0 +1,7 @@ +- Changelog entries are now one file per pull request. A change adds + `changelog.d/-.md` instead of editing the top of + `CHANGELOG.md`, which two pull requests in flight could not share — it was the + most frequent merge conflict in the open queue, and for six of them the only + one. `npm run changelog` splices the directory into `CHANGELOG.md` at release + time and empties it; `npm run changelog:check` reports the batch and changes + nothing. Fragment text is copied through unchanged (daikaginza, #787). From a4ce7381d88f7da849d80010fe1d8979457b754c Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:32:50 -0500 Subject: [PATCH 006/155] fix(ui): enforce Context ownership before disclosure no-op --- src/ui/panelChrome.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/ui/panelChrome.js b/src/ui/panelChrome.js index 30266312a..6464f1588 100644 --- a/src/ui/panelChrome.js +++ b/src/ui/panelChrome.js @@ -474,6 +474,17 @@ export class PanelChrome { syncShare, }); } + // Parent ownership is an invariant even on a no-op disclosure request. + // A stale compact Radio disclosure is absolutely positioned outside the + // measured Context rail; clear it before returning from an already-open + // Context request so it cannot paint over the active Context body. + if ( + !nextCollapsed && + panelId === 'global-context-panel' && + this._contextRadioDock?.classList.contains('disclosure-open') + ) { + this._setRadioDisclosure?.(false); + } if ( panelEl.classList.contains('collapsed') === nextCollapsed && !wasAutoCollapsed @@ -502,13 +513,6 @@ export class PanelChrome { this.cockpitView.setContextCollapsed(true); } } - if ( - !nextCollapsed && - panelId === 'global-context-panel' && - this._contextRadioDock?.classList.contains('disclosure-open') - ) { - this._setRadioDisclosure?.(false); - } if ( !nextCollapsed && panelId === 'radio-panel' && From b79ab84a1a5efaffea6eb2540142b22c34a67ca2 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:33:09 -0500 Subject: [PATCH 007/155] test(ui): pin Context and compact Radio ownership --- src/ui/panelChrome.radioOwnership.test.mjs | 67 ++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/ui/panelChrome.radioOwnership.test.mjs diff --git a/src/ui/panelChrome.radioOwnership.test.mjs b/src/ui/panelChrome.radioOwnership.test.mjs new file mode 100644 index 000000000..c52a4888e --- /dev/null +++ b/src/ui/panelChrome.radioOwnership.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { PanelChrome } from './panelChrome.js'; + +function classList(initial = []) { + const values = new Set(initial); + return { + contains: (value) => values.has(value), + add: (...items) => items.forEach((item) => values.add(item)), + remove: (...items) => items.forEach((item) => values.delete(item)), + toggle(value, force) { + if (force === true) values.add(value); + else if (force === false) values.delete(value); + else if (values.has(value)) values.delete(value); + else values.add(value); + return values.has(value); + }, + }; +} + +test('opening an already-open Context still closes a stale compact Radio disclosure', (t) => { + const priorDocument = globalThis.document; + const contextPanel = { + id: 'global-context-panel', + classList: classList(), + }; + globalThis.document = { + documentElement: { dataset: { uiTheme: 'tactical' } }, + getElementById(id) { + return id === 'global-context-panel' ? contextPanel : null; + }, + }; + t.after(() => { + globalThis.document = priorDocument; + }); + + const radioDock = { classList: classList(['disclosure-open']) }; + const calls = []; + const owner = { + _contextRadioDock: radioDock, + _setRadioDisclosure(open) { + calls.push(open); + radioDock.classList.toggle('disclosure-open', open); + }, + _leftPanelStack: null, + _rightPanelStack: null, + _panelLayout: { + _leftStackPreferredPanelId: null, + _rightStackPreferredPanelId: null, + }, + _syncPanelCollapseButton() {}, + _scheduleLeftPanelLayout() {}, + _scheduleRightPanelLayout() {}, + shareLinkManager: null, + cockpitView: null, + }; + + PanelChrome.prototype.setPanelCollapsed.call( + owner, + 'global-context-panel', + false, + ); + + assert.deepEqual(calls, [false]); + assert.equal(radioDock.classList.contains('disclosure-open'), false); +}); From b22e6d519a0387b846a71a3069dc8c66797bf52d Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:33:11 -0500 Subject: [PATCH 008/155] chore(format): adopt panel ownership test --- scripts/format-scope.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ef44df03f..507ee3126 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -651,6 +651,7 @@ "src/ui/visualSettings.js", "src/ui/panelChrome.js", "src/testSupport/readShellSource.mjs", + "src/ui/panelChrome.radioOwnership.test.mjs", "src/ui/stateOwners.test.mjs", "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", From b681d094ca0e2d0ea0d2cea0b0a6636410ea5e94 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Tue, 29 Sep 2026 10:43:30 +0800 Subject: [PATCH 009/155] docs: correct ADSBDB cache persistence note --- src/layers/flights/policy.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index f8358c98f..87e4617ee 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -213,8 +213,8 @@ export const COURSE_SLEW_DT_MAX_SEC = 0.25; // adsbdb enrichment (best-effort, fail-silent). Bounded fan-out: max 4 // concurrent requests, dispatches dripped ≥ENRICH_DISPATCH_GAP_MS apart // (≤5/s — adsbdb is a free community API; the dev-server proxy additionally -// caches per-key on disk forever, negative results included, so repeat -// sessions never re-hit adsbdb). Each key is requested at most once per +// caches successful and 404 responses per key for 24 hours, so later sessions +// may refresh them). Each key is requested at most once per // session. Priority jobs (tracked plane, model-eligible planes) jump the // queue; the ambient fleet sweep (below) fills the back at poll cadence. // --------------------------------------------------------------------------- From 4fc813e1752b8e38f6051c6ddcaf0e3e3a97947a Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:02 -0500 Subject: [PATCH 010/155] feat(agent): extract model-free World Packet boundary --- src/agent/worldPacket.js | 184 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src/agent/worldPacket.js diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js new file mode 100644 index 000000000..c492f3d6b --- /dev/null +++ b/src/agent/worldPacket.js @@ -0,0 +1,184 @@ +import * as Cesium from 'cesium'; +import { + feedProvenanceEnvelope, + layerSnapshots, + viewStateLayerRow, +} from '../data/layerSnapshot.js'; +import { contextModeWord } from '../contextModePolicy.js'; + +const TRACKABLE_FAMILIES = Object.freeze([ + { layerId: 'flights', kind: 'aircraft' }, + { layerId: 'military', kind: 'aircraft' }, + { layerId: 'ais-live-vessels', kind: 'vessel' }, + { layerId: 'satellites', kind: 'satellite' }, +]); + +const CONTEXT_MODE_RESULT_FIELDS = Object.freeze([ + { field: 'mode', emptyAs: 'off' }, + { field: 'entering', emptyAs: null }, + { field: 'priorMode', emptyAs: null }, +]); + +const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ + 'context', + 'contextRollback', +]); + +const NON_SEMANTIC_PACKET_KEYS = new Set([ + 'note', + 'ageLabel', + 'generatedAt', +]); + +function semanticPacketValue(value) { + if (Array.isArray(value)) return value.map(semanticPacketValue); + if (!value || typeof value !== 'object') return value; + const out = {}; + for (const key of Object.keys(value).sort()) { + if (NON_SEMANTIC_PACKET_KEYS.has(key)) continue; + out[key] = semanticPacketValue(value[key]); + } + return out; +} + +/** + * Stable semantic fingerprint for change detection, not authentication. + * + * Presentation-only narration/age labels are excluded. Feed state, source, + * lastUpdate, camera, context, tracked entities, and other semantic state stay + * inside the digest. + */ +export function semanticWorldPacketFingerprint(packet) { + const canonical = JSON.stringify(semanticPacketValue(packet)); + let hash = 0xcbf29ce484222325n; + const prime = 0x100000001b3n; + for (let i = 0; i < canonical.length; i += 1) { + hash ^= BigInt(canonical.charCodeAt(i)); + hash = BigInt.asUintN(64, hash * prime); + } + return `fnv1a64:${hash.toString(16).padStart(16, '0')}`; +} + +/** + * Translate context-mode payloads into the public tool vocabulary while + * retaining the internal id beside each translated field. + */ +export function withContextModeVocabulary(state) { + if (!state || typeof state !== 'object') return state; + let out = state; + const mutable = () => { + if (out === state) out = { ...state }; + return out; + }; + for (const { field, emptyAs } of CONTEXT_MODE_RESULT_FIELDS) { + if (!(field in state)) continue; + const internal = state[field] ?? null; + const target = mutable(); + target[field] = contextModeWord(internal, { emptyAs }); + target[`${field}Internal`] = internal; + } + for (const field of NESTED_CONTEXT_RESULT_FIELDS) { + const nested = state[field]; + if (!nested || typeof nested !== 'object') continue; + const translated = withContextModeVocabulary(nested); + if (translated !== nested) mutable()[field] = translated; + } + return out; +} + +/** Read tracked entities from their canonical layer owners. */ +export function collectTrackedEntities(dataManager) { + const tracked = []; + for (const family of TRACKABLE_FAMILIES) { + const module = dataManager?.layers?.get(family.layerId)?.module; + if (!module) continue; + try { + const info = + family.kind === 'vessel' + ? module.getSelectedInfo?.() + : module.getTrackedInfo?.(); + if (info) + tracked.push({ + kind: family.kind, + layerId: family.layerId, + ...info, + }); + } catch { + // A not-yet-ready layer contributes no tracked observation. + } + } + return tracked; +} + +/** Read the active Contacts window from its owning awareness layer. */ +export function activeContactsWindow(dataManager) { + try { + const layer = dataManager?.layers?.get('military-awareness')?.module; + return ( + layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null + ); + } catch { + return null; + } +} + +/** + * Build the model-free fast World Packet from existing application owners. + * + * This performs no network requests and intentionally mirrors the historical + * get_current_view_state payload so voice can delegate without behavior drift. + */ +export function getWorldPacket({ + viewer, + styleManager, + dataManager, + sceneDirector = null, + now = Date.now(), +} = {}) { + if (!viewer?.camera?.positionWC) + throw new TypeError('World Packet requires a viewer camera'); + if (!dataManager || typeof dataManager.getAll !== 'function') + throw new TypeError('World Packet requires a data manager'); + + const cartographic = Cesium.Cartographic.fromCartesian( + viewer.camera.positionWC, + ); + const rows = dataManager.getAll(); + const contactsWindow = activeContactsWindow(dataManager); + const contextState = + typeof styleManager?.getContextModeState === 'function' + ? withContextModeVocabulary(styleManager.getContextModeState()) + : null; + + return { + ok: true, + action: 'get_current_view_state', + camera: { + latitude: Cesium.Math.toDegrees(cartographic.latitude), + longitude: Cesium.Math.toDegrees(cartographic.longitude), + heightM: cartographic.height, + }, + style: styleManager?.activeStyle || 'normal', + context: contextState + ? { + ...contextState, + ...(contactsWindow ? { contactsWindow } : {}), + } + : null, + cockpit: + typeof styleManager?.getCockpitState === 'function' + ? styleManager.getCockpitState() + : null, + controls: + typeof styleManager?.getControlState === 'function' + ? styleManager.getControlState() + : null, + scenePlayback: sceneDirector?.getPlaybackStatus?.() || null, + tracked: collectTrackedEntities(dataManager), + layers: rows.map((layer) => viewStateLayerRow(layer, { now })), + feedProvenance: feedProvenanceEnvelope( + layerSnapshots(rows, { now }).filter((layer) => layer.enabled), + { now }, + ), + }; +} From 5c044a3d8da61eb707c7b61e16e42d6c72fc2ad3 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:05 -0500 Subject: [PATCH 011/155] test(agent): prove World Packet parity and fingerprint semantics --- src/agent/worldPacket.test.mjs | 110 +++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/agent/worldPacket.test.mjs diff --git a/src/agent/worldPacket.test.mjs b/src/agent/worldPacket.test.mjs new file mode 100644 index 000000000..9d9e0bd7d --- /dev/null +++ b/src/agent/worldPacket.test.mjs @@ -0,0 +1,110 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; + +import { + getWorldPacket, + semanticWorldPacketFingerprint, +} from './worldPacket.js'; +import { createGevActionRunner } from '../voice/gevActions.js'; + +function harness() { + const position = Cesium.Cartesian3.fromDegrees(-97.74, 30.26, 500); + const viewer = { + trackedEntity: undefined, + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { + canvas: { + clientWidth: 1200, + clientHeight: 800, + addEventListener() {}, + removeEventListener() {}, + }, + globe: { getHeight: () => 0 }, + tweens: [], + }, + camera: { + positionWC: position, + positionCartographic: Cesium.Cartographic.fromCartesian(position), + heading: 0, + pitch: Cesium.Math.toRadians(-45), + moveEnd: { addEventListener: () => () => {} }, + cancelFlight() {}, + lookAtTransform() {}, + }, + }; + const rows = [ + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 3, + source: 'fixture', + lastUpdate: null, + }, + }, + ]; + const dataManager = { + layers: new Map([['traffic', { module: null }]]), + getAll: () => rows, + }; + const styleManager = { + activeStyle: 'normal', + getContextModeState: () => ({ mode: 'flights' }), + getCockpitState: () => null, + getControlState: () => ({ hud: { visible: 'auto' } }), + }; + return { viewer, dataManager, styleManager }; +} + +test('World Packet preserves get_current_view_state parity', async () => { + const { viewer, dataManager, styleManager } = harness(); + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + const runner = createGevActionRunner({ viewer, dataManager, styleManager }); + const voice = await runner('get_current_view_state'); + + assert.deepEqual(voice, packet); + assert.equal(packet.context.mode, 'contacts'); + assert.equal(packet.context.modeInternal, 'flights'); + assert.equal(packet.layers[0].feedState, 'nominal'); + assert.equal(packet.feedProvenance.overall, 'nominal'); +}); + +test('World Packet keeps stale/unavailable feed semantics distinct from empty', () => { + const { viewer, dataManager, styleManager } = harness(); + dataManager.getAll = () => [ + { + id: 'traffic', + name: 'Traffic', + enabled: true, + stats: { + count: 0, + source: 'fixture', + error: 'fixture unavailable', + lastUpdate: null, + }, + }, + ]; + + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + assert.equal(packet.layers[0].count, 0); + assert.notEqual(packet.layers[0].feedState, 'nominal'); + assert.notEqual(packet.feedProvenance.overall, 'nominal'); + assert.match(packet.feedProvenance.note, /fixture unavailable/i); +}); + + +test('semantic fingerprint ignores narration prose but changes with observed state', () => { + const { viewer, dataManager, styleManager } = harness(); + const packet = getWorldPacket({ viewer, dataManager, styleManager }); + const original = semanticWorldPacketFingerprint(packet); + + const proseOnly = structuredClone(packet); + proseOnly.feedProvenance.note = 'different generated narration'; + assert.equal(semanticWorldPacketFingerprint(proseOnly), original); + + const changed = structuredClone(packet); + changed.layers[0].count += 1; + assert.notEqual(semanticWorldPacketFingerprint(changed), original); +}); From 3c163d09a15f2a152cb6ce6de09ff0e82db1a63e Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:08 -0500 Subject: [PATCH 012/155] refactor(voice): delegate world state to World Packet builder --- src/voice/gevActions.js | 73 ++++------------------------------------- 1 file changed, 7 insertions(+), 66 deletions(-) diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index cfdb1fb0b..cccae249e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -39,6 +39,7 @@ import { unavailablePlaceSearch } from '../search/placeSearch.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; import { normalizeRadioCountryInput } from '../data/radioCountry.js'; import { TR3B_CLASS } from '../data/tr3bRegistry.js'; +import { getWorldPacket } from '../agent/worldPacket.js'; const ALLOWED_STYLES = new Set([ 'normal', @@ -2424,25 +2425,6 @@ function runManagedVoiceNavigation( } /** Gathers tracked/selected entities across layer families for read-back. */ -function collectTrackedEntities(dataManager) { - const tracked = []; - for (const family of TRACKABLE_FAMILIES) { - const module = dataManager.layers.get(family.layerId)?.module; - if (!module) continue; - try { - const info = - family.kind === 'vessel' - ? module.getSelectedInfo?.() - : module.getTrackedInfo?.(); - if (info) - tracked.push({ kind: family.kind, layerId: family.layerId, ...info }); - } catch { - // layer not ready - } - } - return tracked; -} - export async function getBasemapLabelContext( viewer, service = defaultGeospatial, @@ -3073,53 +3055,12 @@ function getCurrentViewState( dataManager, sceneDirector = null, ) { - const cartographic = Cesium.Cartographic.fromCartesian( - viewer.camera.positionWC, - ); - return { - ok: true, - action: 'get_current_view_state', - camera: { - latitude: Cesium.Math.toDegrees(cartographic.latitude), - longitude: Cesium.Math.toDegrees(cartographic.longitude), - heightM: cartographic.height, - }, - style: styleManager.activeStyle || 'normal', - context: - typeof styleManager.getContextModeState === 'function' - ? { - ...withContextModeVocabulary(styleManager.getContextModeState()), - // The numbers on the operator's Contacts panel, so a window/count - // question can be answered from what they are looking at. - ...(activeContactsWindow(dataManager) - ? { contactsWindow: activeContactsWindow(dataManager) } - : {}), - } - : null, - cockpit: - typeof styleManager.getCockpitState === 'function' - ? styleManager.getCockpitState() - : null, - controls: - typeof styleManager.getControlState === 'function' - ? styleManager.getControlState() - : null, - scenePlayback: sceneDirector?.getPlaybackStatus?.() || null, - tracked: collectTrackedEntities(dataManager), - layers: dataManager.getAll().map((layer) => ({ - id: layer.id, - name: layer.name, - enabled: layer.enabled, - count: layer.stats?.count || 0, - error: layer.stats?.error || null, - feedState: layerSnapshot(layer).feedState, - source: layerSnapshot(layer).source, - lastUpdate: layerSnapshot(layer).lastUpdate, - })), - feedProvenance: feedProvenanceEnvelope( - layerSnapshots(dataManager.getAll()).filter((layer) => layer.enabled), - ), - }; + return getWorldPacket({ + viewer, + styleManager, + dataManager, + sceneDirector, + }); } async function getEntityContext( From 6439575d158238ce92470106d221f5d450c6f29c Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:11 -0500 Subject: [PATCH 013/155] feat(agent): export World Packet boundary --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e48881c6d..0a299b51d 100644 --- a/package.json +++ b/package.json @@ -250,6 +250,7 @@ "./layers/transit/standalone": "./src/data/transit.js", "./layers/transit/source": "./src/layers/transit/source.js", "./sources/transit-service": "./src/sources/transitService.js", - "./sources/map-features": "./src/sources/overpassFeatures.js" + "./sources/map-features": "./src/sources/overpassFeatures.js", + "./agent/world-packet": "./src/agent/worldPacket.js" } } From c55d4d7c25877abb1a6eec331d82ad0a124bf61e Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:02:14 -0500 Subject: [PATCH 014/155] build: register World Packet package boundary --- scripts/package-boundaries.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 276b9e0db..42e98d736 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -3072,5 +3072,15 @@ "src/layers/perimeters/source.js" ], "external": [] + }, + "agent-world-packet": { + "exports": ["./agent/world-packet"], + "modules": [ + "src/agent/worldPacket.js", + "src/data/layerSnapshot.js", + "src/data/feedState.js", + "src/contextModePolicy.js" + ], + "external": ["cesium"] } } From 05b13e9a78b091d1426e5a27889ad274d4cb350b Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:51:48 -0500 Subject: [PATCH 015/155] chore: add one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml new file mode 100644 index 000000000..73af025ad --- /dev/null +++ b/.github/workflows/tmp-format-fix.yml @@ -0,0 +1,37 @@ +name: Temporary branch formatter + +on: + push: + branches: + - feat/world-packet-boundary-clean-20260929 + +permissions: + contents: write + +jobs: + format: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + env: + PUPPETEER_SKIP_DOWNLOAD: 1 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: feat/world-packet-boundary-clean-20260929 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + - run: npm run format + - name: Commit formatting + shell: bash + run: | + if git diff --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -u + git commit -m "style: apply adopted formatting" + git push origin HEAD:feat/world-packet-boundary-clean-20260929 From 58b7d2d95e7ba3e323a5bd506946f4687df670a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:52:28 +0000 Subject: [PATCH 016/155] style: apply adopted formatting --- src/agent/worldPacket.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/agent/worldPacket.js b/src/agent/worldPacket.js index c492f3d6b..e8c522caa 100644 --- a/src/agent/worldPacket.js +++ b/src/agent/worldPacket.js @@ -24,11 +24,7 @@ const NESTED_CONTEXT_RESULT_FIELDS = Object.freeze([ 'contextRollback', ]); -const NON_SEMANTIC_PACKET_KEYS = new Set([ - 'note', - 'ageLabel', - 'generatedAt', -]); +const NON_SEMANTIC_PACKET_KEYS = new Set(['note', 'ageLabel', 'generatedAt']); function semanticPacketValue(value) { if (Array.isArray(value)) return value.map(semanticPacketValue); From 6a6703e0c6db8bca4ac9ab7aa27c6df931106f5c Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:52:54 -0500 Subject: [PATCH 017/155] chore: remove one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ---------------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml deleted file mode 100644 index 73af025ad..000000000 --- a/.github/workflows/tmp-format-fix.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Temporary branch formatter - -on: - push: - branches: - - feat/world-packet-boundary-clean-20260929 - -permissions: - contents: write - -jobs: - format: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - env: - PUPPETEER_SKIP_DOWNLOAD: 1 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: feat/world-packet-boundary-clean-20260929 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - - run: npm run format - - name: Commit formatting - shell: bash - run: | - if git diff --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -u - git commit -m "style: apply adopted formatting" - git push origin HEAD:feat/world-packet-boundary-clean-20260929 From 3c53d63defb180199a039787412e6aa811d43b2d Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:55:09 -0500 Subject: [PATCH 018/155] fix: declare World Packet in application boundary --- scripts/package-boundaries.json | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 42e98d736..b362d28f4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1851,6 +1851,7 @@ "src/voice/commands.js", "src/voice/control.js", "src/voice/gevActions.js", + "src/agent/worldPacket.js", "src/voice/gevRealtime.js", "src/voice/layerSummary.js", "src/voice/realtimeBackend.js", From 8acb526d1fc28dbec508b5303f1865bc44c099f2 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:57:17 -0500 Subject: [PATCH 019/155] fix: include World Packet feed-state dependencies --- scripts/package-boundaries.json | 746 ++++++++++++++++++++++++-------- 1 file changed, 567 insertions(+), 179 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index b362d28f4..260179257 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,10 +477,14 @@ "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", @@ -384,7 +494,10 @@ "external": [] }, "layer-panel": { - "exports": ["./ui/layers", "./ui/layers/feedback"], + "exports": [ + "./ui/layers", + "./ui/layers/feedback" + ], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -404,7 +517,9 @@ "external": [] }, "location-controls": { - "exports": ["./ui/location"], + "exports": [ + "./ui/location" + ], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -415,7 +530,9 @@ "external": [] }, "radio-controls": { - "exports": ["./ui/radio"], + "exports": [ + "./ui/radio" + ], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", @@ -426,7 +543,9 @@ "external": [] }, "cctv-controls": { - "exports": ["./ui/cctv"], + "exports": [ + "./ui/cctv" + ], "modules": [ "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -439,7 +558,10 @@ "external": [] }, "context-controls": { - "exports": ["./ui/context", "./ui/context/policy"], + "exports": [ + "./ui/context", + "./ui/context/policy" + ], "modules": [ "src/ui/context.js", "src/ui/contextActions.js", @@ -482,10 +604,14 @@ "src/cockpitVisionPolicy.js", "src/ui/panelRailGeometry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "scene-controls": { - "exports": ["./ui/scenes"], + "exports": [ + "./ui/scenes" + ], "modules": [ "src/ui/scenes.js", "src/ui/sceneControls.js", @@ -494,7 +620,9 @@ "external": [] }, "ui-shell": { - "exports": ["./ui/shell"], + "exports": [ + "./ui/shell" + ], "modules": [ "src/hudLayouts.js", "src/hudLayoutPolicy.js", @@ -624,10 +752,14 @@ "src/ui/localSdrPresentation.js", "src/data/cameraArrival.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-styles": { - "exports": ["./ui/styles"], + "exports": [ + "./ui/styles" + ], "modules": [ "src/ui/styles.js", "style.css", @@ -657,7 +789,9 @@ "external": [] }, "director": { - "exports": ["./director"], + "exports": [ + "./director" + ], "modules": [ "src/director/playback.js", "src/director/clock.js", @@ -680,7 +814,9 @@ "external": [] }, "scenes": { - "exports": ["./scenes"], + "exports": [ + "./scenes" + ], "modules": [ "src/scenes/nepalEvidencePack.js", "src/data/bhoteKoshiIncidentPlaces.js", @@ -729,10 +865,14 @@ "src/ui/sceneSharing.js", "src/data/cameraArrival.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 +883,9 @@ "external": [] }, "civil-flights": { - "exports": ["./layers/flights"], + "exports": [ + "./layers/flights" + ], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -782,10 +924,14 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "military-flights": { - "exports": ["./layers/military"], + "exports": [ + "./layers/military" + ], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -820,10 +966,14 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "aircraft-mechanics": { - "exports": ["./aircraft"], + "exports": [ + "./aircraft" + ], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -845,10 +995,14 @@ "src/layers/aircraft/index.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "vessel-layer": { - "exports": ["./layers/vessels"], + "exports": [ + "./layers/vessels" + ], "modules": [ "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -869,20 +1023,28 @@ "src/layers/vessels/snapshotRenderer.js", "src/data/inputOwnership.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,10 +1053,14 @@ "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", @@ -914,10 +1080,14 @@ "src/layers/firms/viewport.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "satellites-layer": { - "exports": ["./layers/satellites"], + "exports": [ + "./layers/satellites" + ], "modules": [ "src/data/issPass.js", "src/data/satelliteClass.js", @@ -939,10 +1109,15 @@ "src/layers/satellites/tracking.js", "src/data/inputOwnership.js" ], - "external": ["cesium", "satellite.js"] + "external": [ + "cesium", + "satellite.js" + ] }, "launches-layer": { - "exports": ["./layers/launches"], + "exports": [ + "./layers/launches" + ], "modules": [ "src/cyberSonar.js", "src/layers/launches/camera.js", @@ -966,10 +1141,14 @@ "src/layers/launches/testing.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "installations-layer": { - "exports": ["./layers/installations"], + "exports": [ + "./layers/installations" + ], "modules": [ "src/data/cameraSensitivity.js", "src/data/installationFeedback.js", @@ -1001,10 +1180,16 @@ "src/layers/installations/namedMarkers.js", "src/data/infrastructureOverlayEntry.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", @@ -1028,10 +1213,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", @@ -1069,10 +1258,16 @@ "src/sources/phaseTiming.js", "src/layers/traffic/roadModes.js" ], - "external": ["cesium", "pbf", "@mapbox/vector-tile"] + "external": [ + "cesium", + "pbf", + "@mapbox/vector-tile" + ] }, "bikeshare-layer": { - "exports": ["./layers/bikeshare"], + "exports": [ + "./layers/bikeshare" + ], "modules": [ "src/layers/bikeshare/controls.js", "src/layers/bikeshare/index.js", @@ -1091,10 +1286,14 @@ "src/data/inputOwnership.js", "src/data/cameraSensitivity.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "cctv-layer": { - "exports": ["./layers/cctv"], + "exports": [ + "./layers/cctv" + ], "modules": [ "src/data/cctvCards.js", "src/data/cctvGizmo.js", @@ -1132,10 +1331,15 @@ "src/data/inputOwnership.js", "src/layers/cctv/sourcePolicy.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,10 +1358,16 @@ "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/layerState.js", @@ -1188,10 +1398,14 @@ "src/ui/radioTunerModel.js", "src/data/inputOwnership.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "submarine-cables": { - "exports": ["./layers/submarine-cables"], + "exports": [ + "./layers/submarine-cables" + ], "modules": [ "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", @@ -1205,10 +1419,14 @@ "src/layers/submarineCables/surface.js", "src/data/inputOwnership.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 +1435,10 @@ "external": [] }, "geography": { - "exports": ["./geography/natural-earth", "./geography/neighborhoods"], + "exports": [ + "./geography/natural-earth", + "./geography/neighborhoods" + ], "modules": [ "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", @@ -1253,7 +1474,9 @@ "src/keySetupCore.mjs", "src/maps/imageryHost.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "voice-connection": { "exports": [ @@ -2042,12 +2265,18 @@ ] }, "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", @@ -2526,7 +2755,9 @@ ] }, "application-operations": { - "exports": ["./application/operations"], + "exports": [ + "./application/operations" + ], "modules": [ "src/annotations/annotationResolver.js", "src/annotations/resolver.js", @@ -2587,22 +2818,33 @@ ] }, "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", @@ -2615,7 +2857,9 @@ "external": [] }, "ui-visual-settings": { - "exports": ["./ui/visual-settings"], + "exports": [ + "./ui/visual-settings" + ], "modules": [ "src/cyberSonar.js", "src/hudLayouts.js", @@ -2640,10 +2884,14 @@ "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", @@ -2662,7 +2910,9 @@ "external": [] }, "civil-flight-records": { - "exports": ["./layers/flights/records"], + "exports": [ + "./layers/flights/records" + ], "modules": [ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", @@ -2673,7 +2923,9 @@ "external": [] }, "civil-flight-ingestion": { - "exports": ["./layers/flights/ingestion"], + "exports": [ + "./layers/flights/ingestion" + ], "modules": [ "src/layers/flights/ingestion.js", "src/layers/flights/recordPolicy.js" @@ -2681,7 +2933,9 @@ "external": [] }, "military-flight-records": { - "exports": ["./layers/military/records"], + "exports": [ + "./layers/military/records" + ], "modules": [ "src/layers/military/records.js", "src/layers/military/recordPolicy.js", @@ -2692,7 +2946,9 @@ "external": [] }, "military-flight-ingestion": { - "exports": ["./layers/military/ingestion"], + "exports": [ + "./layers/military/ingestion" + ], "modules": [ "src/layers/military/ingestion.js", "src/layers/military/recordPolicy.js" @@ -2700,7 +2956,9 @@ "external": [] }, "vessel-records": { - "exports": ["./layers/vessels/records"], + "exports": [ + "./layers/vessels/records" + ], "modules": [ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js" @@ -2708,7 +2966,9 @@ "external": [] }, "vessel-ingestion": { - "exports": ["./layers/vessels/ingestion"], + "exports": [ + "./layers/vessels/ingestion" + ], "modules": [ "src/layers/vessels/ingestion.js", "src/layers/vessels/recordPolicy.js" @@ -2716,37 +2976,68 @@ "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/voice/session.js", @@ -2755,7 +3046,9 @@ "external": [] }, "alpr-source": { - "exports": ["./layers/alpr/source"], + "exports": [ + "./layers/alpr/source" + ], "modules": [ "src/layers/alpr/policy.js", "src/layers/alpr/records.js", @@ -2766,20 +3059,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" @@ -2787,12 +3094,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", @@ -2809,25 +3122,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", @@ -2843,10 +3174,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", @@ -2859,7 +3196,9 @@ "external": [] }, "nominatim-provider": { - "exports": ["./search/nominatim"], + "exports": [ + "./search/nominatim" + ], "modules": [ "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -2872,17 +3211,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", @@ -2894,7 +3243,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", @@ -2909,20 +3260,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", @@ -2981,10 +3340,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", @@ -3018,10 +3383,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" @@ -3029,7 +3399,9 @@ "external": [] }, "transit-service": { - "exports": ["./sources/transit-service"], + "exports": [ + "./sources/transit-service" + ], "modules": [ "src/data/transitFeeds.js", "src/data/transitProxy.js", @@ -3040,10 +3412,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", @@ -3054,7 +3430,9 @@ "external": [] }, "perimeters": { - "exports": ["./layers/perimeters"], + "exports": [ + "./layers/perimeters" + ], "modules": [ "src/layers/perimeters/index.js", "src/sources/httpBody.js", @@ -3063,10 +3441,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", @@ -3075,13 +3457,19 @@ "external": [] }, "agent-world-packet": { - "exports": ["./agent/world-packet"], + "exports": [ + "./agent/world-packet" + ], "modules": [ "src/agent/worldPacket.js", "src/data/layerSnapshot.js", "src/data/feedState.js", - "src/contextModePolicy.js" + "src/contextModePolicy.js", + "src/loadingFeedback.js", + "src/data/installationFeedback.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] } } From cbd3b13afb022cf1904dd08e809d58167f6e7340 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:58:20 -0500 Subject: [PATCH 020/155] chore: add one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml new file mode 100644 index 000000000..73af025ad --- /dev/null +++ b/.github/workflows/tmp-format-fix.yml @@ -0,0 +1,37 @@ +name: Temporary branch formatter + +on: + push: + branches: + - feat/world-packet-boundary-clean-20260929 + +permissions: + contents: write + +jobs: + format: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + env: + PUPPETEER_SKIP_DOWNLOAD: 1 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: feat/world-packet-boundary-clean-20260929 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + - run: npm run format + - name: Commit formatting + shell: bash + run: | + if git diff --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -u + git commit -m "style: apply adopted formatting" + git push origin HEAD:feat/world-packet-boundary-clean-20260929 From 914df05346d50adf13abd3337e0ab1d65daf19b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 01:59:00 +0000 Subject: [PATCH 021/155] style: apply adopted formatting --- scripts/package-boundaries.json | 742 ++++++++------------------------ 1 file changed, 178 insertions(+), 564 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 260179257..fa4f872a4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -12,50 +12,27 @@ "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", @@ -71,24 +48,16 @@ "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", @@ -106,19 +75,13 @@ "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", @@ -130,19 +93,13 @@ "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" @@ -150,59 +107,35 @@ "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", @@ -212,19 +145,13 @@ "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", @@ -249,9 +176,7 @@ }, "radio-provider": { "runtime": "node", - "exports": [ - "./server/providers/radio" - ], + "exports": ["./server/providers/radio"], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -267,9 +192,7 @@ }, "overpass-provider": { "runtime": "node", - "exports": [ - "./server/providers/overpass" - ], + "exports": ["./server/providers/overpass"], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -292,9 +215,7 @@ }, "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", @@ -313,9 +234,7 @@ }, "regional-provider": { "runtime": "node", - "exports": [ - "./server/providers/regional" - ], + "exports": ["./server/providers/regional"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -342,9 +261,7 @@ }, "openai-provider": { "runtime": "node", - "exports": [ - "./server/providers/openai" - ], + "exports": ["./server/providers/openai"], "modules": [ "src/hudLayoutPolicy.js", "server/providers/common/rate-limit.js", @@ -372,9 +289,7 @@ }, "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", @@ -385,9 +300,7 @@ "external": [] }, "place-search": { - "exports": [ - "./search" - ], + "exports": ["./search"], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -408,27 +321,17 @@ "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", @@ -441,9 +344,7 @@ "external": [] }, "visual-input": { - "exports": [ - "./ui/input" - ], + "exports": ["./ui/input"], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -452,19 +353,12 @@ "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", @@ -477,14 +371,10 @@ "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", @@ -494,10 +384,7 @@ "external": [] }, "layer-panel": { - "exports": [ - "./ui/layers", - "./ui/layers/feedback" - ], + "exports": ["./ui/layers", "./ui/layers/feedback"], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -517,9 +404,7 @@ "external": [] }, "location-controls": { - "exports": [ - "./ui/location" - ], + "exports": ["./ui/location"], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -530,9 +415,7 @@ "external": [] }, "radio-controls": { - "exports": [ - "./ui/radio" - ], + "exports": ["./ui/radio"], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", @@ -543,9 +426,7 @@ "external": [] }, "cctv-controls": { - "exports": [ - "./ui/cctv" - ], + "exports": ["./ui/cctv"], "modules": [ "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -558,10 +439,7 @@ "external": [] }, "context-controls": { - "exports": [ - "./ui/context", - "./ui/context/policy" - ], + "exports": ["./ui/context", "./ui/context/policy"], "modules": [ "src/ui/context.js", "src/ui/contextActions.js", @@ -604,14 +482,10 @@ "src/cockpitVisionPolicy.js", "src/ui/panelRailGeometry.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "scene-controls": { - "exports": [ - "./ui/scenes" - ], + "exports": ["./ui/scenes"], "modules": [ "src/ui/scenes.js", "src/ui/sceneControls.js", @@ -620,9 +494,7 @@ "external": [] }, "ui-shell": { - "exports": [ - "./ui/shell" - ], + "exports": ["./ui/shell"], "modules": [ "src/hudLayouts.js", "src/hudLayoutPolicy.js", @@ -752,14 +624,10 @@ "src/ui/localSdrPresentation.js", "src/data/cameraArrival.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "ui-styles": { - "exports": [ - "./ui/styles" - ], + "exports": ["./ui/styles"], "modules": [ "src/ui/styles.js", "style.css", @@ -789,9 +657,7 @@ "external": [] }, "director": { - "exports": [ - "./director" - ], + "exports": ["./director"], "modules": [ "src/director/playback.js", "src/director/clock.js", @@ -814,9 +680,7 @@ "external": [] }, "scenes": { - "exports": [ - "./scenes" - ], + "exports": ["./scenes"], "modules": [ "src/scenes/nepalEvidencePack.js", "src/data/bhoteKoshiIncidentPlaces.js", @@ -865,14 +729,10 @@ "src/ui/sceneSharing.js", "src/data/cameraArrival.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "live-observations": { - "exports": [ - "./sources/live" - ], + "exports": ["./sources/live"], "modules": [ "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -883,9 +743,7 @@ "external": [] }, "civil-flights": { - "exports": [ - "./layers/flights" - ], + "exports": ["./layers/flights"], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -924,14 +782,10 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "military-flights": { - "exports": [ - "./layers/military" - ], + "exports": ["./layers/military"], "modules": [ "src/cyberSonar.js", "src/data/aircraftClass.js", @@ -966,14 +820,10 @@ "src/data/inputOwnership.js", "src/data/modelEligibility.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "aircraft-mechanics": { - "exports": [ - "./aircraft" - ], + "exports": ["./aircraft"], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -995,14 +845,10 @@ "src/layers/aircraft/index.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "vessel-layer": { - "exports": [ - "./layers/vessels" - ], + "exports": ["./layers/vessels"], "modules": [ "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -1023,28 +869,20 @@ "src/layers/vessels/snapshotRenderer.js", "src/data/inputOwnership.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", @@ -1053,14 +891,10 @@ "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", @@ -1080,14 +914,10 @@ "src/layers/firms/viewport.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "satellites-layer": { - "exports": [ - "./layers/satellites" - ], + "exports": ["./layers/satellites"], "modules": [ "src/data/issPass.js", "src/data/satelliteClass.js", @@ -1109,15 +939,10 @@ "src/layers/satellites/tracking.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium", - "satellite.js" - ] + "external": ["cesium", "satellite.js"] }, "launches-layer": { - "exports": [ - "./layers/launches" - ], + "exports": ["./layers/launches"], "modules": [ "src/cyberSonar.js", "src/layers/launches/camera.js", @@ -1141,14 +966,10 @@ "src/layers/launches/testing.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "installations-layer": { - "exports": [ - "./layers/installations" - ], + "exports": ["./layers/installations"], "modules": [ "src/data/cameraSensitivity.js", "src/data/installationFeedback.js", @@ -1180,16 +1001,10 @@ "src/layers/installations/namedMarkers.js", "src/data/infrastructureOverlayEntry.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", @@ -1213,14 +1028,10 @@ "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", @@ -1258,16 +1069,10 @@ "src/sources/phaseTiming.js", "src/layers/traffic/roadModes.js" ], - "external": [ - "cesium", - "pbf", - "@mapbox/vector-tile" - ] + "external": ["cesium", "pbf", "@mapbox/vector-tile"] }, "bikeshare-layer": { - "exports": [ - "./layers/bikeshare" - ], + "exports": ["./layers/bikeshare"], "modules": [ "src/layers/bikeshare/controls.js", "src/layers/bikeshare/index.js", @@ -1286,14 +1091,10 @@ "src/data/inputOwnership.js", "src/data/cameraSensitivity.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "cctv-layer": { - "exports": [ - "./layers/cctv" - ], + "exports": ["./layers/cctv"], "modules": [ "src/data/cctvCards.js", "src/data/cctvGizmo.js", @@ -1331,15 +1132,10 @@ "src/data/inputOwnership.js", "src/layers/cctv/sourcePolicy.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", @@ -1358,16 +1154,10 @@ "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/layerState.js", @@ -1398,14 +1188,10 @@ "src/ui/radioTunerModel.js", "src/data/inputOwnership.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "submarine-cables": { - "exports": [ - "./layers/submarine-cables" - ], + "exports": ["./layers/submarine-cables"], "modules": [ "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", @@ -1419,14 +1205,10 @@ "src/layers/submarineCables/surface.js", "src/data/inputOwnership.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", @@ -1435,10 +1217,7 @@ "external": [] }, "geography": { - "exports": [ - "./geography/natural-earth", - "./geography/neighborhoods" - ], + "exports": ["./geography/natural-earth", "./geography/neighborhoods"], "modules": [ "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", @@ -1474,9 +1253,7 @@ "src/keySetupCore.mjs", "src/maps/imageryHost.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "voice-connection": { "exports": [ @@ -2265,18 +2042,12 @@ ] }, "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", @@ -2755,9 +2526,7 @@ ] }, "application-operations": { - "exports": [ - "./application/operations" - ], + "exports": ["./application/operations"], "modules": [ "src/annotations/annotationResolver.js", "src/annotations/resolver.js", @@ -2818,33 +2587,22 @@ ] }, "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", @@ -2857,9 +2615,7 @@ "external": [] }, "ui-visual-settings": { - "exports": [ - "./ui/visual-settings" - ], + "exports": ["./ui/visual-settings"], "modules": [ "src/cyberSonar.js", "src/hudLayouts.js", @@ -2884,14 +2640,10 @@ "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", @@ -2910,9 +2662,7 @@ "external": [] }, "civil-flight-records": { - "exports": [ - "./layers/flights/records" - ], + "exports": ["./layers/flights/records"], "modules": [ "src/layers/flights/records.js", "src/layers/flights/recordPolicy.js", @@ -2923,9 +2673,7 @@ "external": [] }, "civil-flight-ingestion": { - "exports": [ - "./layers/flights/ingestion" - ], + "exports": ["./layers/flights/ingestion"], "modules": [ "src/layers/flights/ingestion.js", "src/layers/flights/recordPolicy.js" @@ -2933,9 +2681,7 @@ "external": [] }, "military-flight-records": { - "exports": [ - "./layers/military/records" - ], + "exports": ["./layers/military/records"], "modules": [ "src/layers/military/records.js", "src/layers/military/recordPolicy.js", @@ -2946,9 +2692,7 @@ "external": [] }, "military-flight-ingestion": { - "exports": [ - "./layers/military/ingestion" - ], + "exports": ["./layers/military/ingestion"], "modules": [ "src/layers/military/ingestion.js", "src/layers/military/recordPolicy.js" @@ -2956,9 +2700,7 @@ "external": [] }, "vessel-records": { - "exports": [ - "./layers/vessels/records" - ], + "exports": ["./layers/vessels/records"], "modules": [ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js" @@ -2966,9 +2708,7 @@ "external": [] }, "vessel-ingestion": { - "exports": [ - "./layers/vessels/ingestion" - ], + "exports": ["./layers/vessels/ingestion"], "modules": [ "src/layers/vessels/ingestion.js", "src/layers/vessels/recordPolicy.js" @@ -2976,68 +2716,37 @@ "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/voice/session.js", @@ -3046,9 +2755,7 @@ "external": [] }, "alpr-source": { - "exports": [ - "./layers/alpr/source" - ], + "exports": ["./layers/alpr/source"], "modules": [ "src/layers/alpr/policy.js", "src/layers/alpr/records.js", @@ -3059,34 +2766,20 @@ "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" @@ -3094,18 +2787,12 @@ "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", @@ -3122,43 +2809,25 @@ "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", @@ -3174,16 +2843,10 @@ "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", @@ -3196,9 +2859,7 @@ "external": [] }, "nominatim-provider": { - "exports": [ - "./search/nominatim" - ], + "exports": ["./search/nominatim"], "modules": [ "src/search/nominatim.js", "src/nominatimGeocode.js", @@ -3211,27 +2872,17 @@ "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", @@ -3243,9 +2894,7 @@ }, "transit-provider": { "runtime": "node", - "exports": [ - "./server/providers/transit" - ], + "exports": ["./server/providers/transit"], "modules": [ "server/providers/common/http.js", "server/providers/common/rate-limit.js", @@ -3260,28 +2909,20 @@ "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", @@ -3340,16 +2981,10 @@ "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", @@ -3383,15 +3018,10 @@ "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" @@ -3399,9 +3029,7 @@ "external": [] }, "transit-service": { - "exports": [ - "./sources/transit-service" - ], + "exports": ["./sources/transit-service"], "modules": [ "src/data/transitFeeds.js", "src/data/transitProxy.js", @@ -3412,14 +3040,10 @@ "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", @@ -3430,9 +3054,7 @@ "external": [] }, "perimeters": { - "exports": [ - "./layers/perimeters" - ], + "exports": ["./layers/perimeters"], "modules": [ "src/layers/perimeters/index.js", "src/sources/httpBody.js", @@ -3441,14 +3063,10 @@ "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", @@ -3457,9 +3075,7 @@ "external": [] }, "agent-world-packet": { - "exports": [ - "./agent/world-packet" - ], + "exports": ["./agent/world-packet"], "modules": [ "src/agent/worldPacket.js", "src/data/layerSnapshot.js", @@ -3468,8 +3084,6 @@ "src/loadingFeedback.js", "src/data/installationFeedback.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] } } From abea3f2feb99c14a64e5c1884292411b044a0bb2 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:59:48 -0500 Subject: [PATCH 022/155] chore: remove one-shot branch formatter --- .github/workflows/tmp-format-fix.yml | 37 ---------------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/tmp-format-fix.yml diff --git a/.github/workflows/tmp-format-fix.yml b/.github/workflows/tmp-format-fix.yml deleted file mode 100644 index 73af025ad..000000000 --- a/.github/workflows/tmp-format-fix.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Temporary branch formatter - -on: - push: - branches: - - feat/world-packet-boundary-clean-20260929 - -permissions: - contents: write - -jobs: - format: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-latest - env: - PUPPETEER_SKIP_DOWNLOAD: 1 - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - ref: feat/world-packet-boundary-clean-20260929 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - - run: npm run format - - name: Commit formatting - shell: bash - run: | - if git diff --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -u - git commit -m "style: apply adopted formatting" - git push origin HEAD:feat/world-packet-boundary-clean-20260929 From a194da21dd6f1fdb4d41170e560e759f5244cf16 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:52:08 -0500 Subject: [PATCH 023/155] chore: add one-shot downstream format fixer --- .github/workflows/downstream-format-fix.yml | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/downstream-format-fix.yml diff --git a/.github/workflows/downstream-format-fix.yml b/.github/workflows/downstream-format-fix.yml new file mode 100644 index 000000000..40b244d2a --- /dev/null +++ b/.github/workflows/downstream-format-fix.yml @@ -0,0 +1,37 @@ +name: Downstream format fix + +on: + push: + branches: + - feat/world-packet-boundary-clean-20260929 + +permissions: + contents: write + +jobs: + format: + if: ${{ github.actor != 'github-actions[bot]' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + ref: feat/world-packet-boundary-clean-20260929 + - uses: actions/setup-node@v5 + with: + node-version: 24.14.0 + cache: npm + - run: npm ci + env: + PUPPETEER_SKIP_DOWNLOAD: 1 + - run: npm run format + - name: Commit formatting if needed + shell: bash + run: | + if git diff --quiet; then + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "chore: apply adopted formatting" + git push From 2b7f832f456c933811c8dc0faa75caf111816718 Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:52:50 -0500 Subject: [PATCH 024/155] chore: remove one-shot downstream format fixer --- .github/workflows/downstream-format-fix.yml | 37 --------------------- 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/downstream-format-fix.yml diff --git a/.github/workflows/downstream-format-fix.yml b/.github/workflows/downstream-format-fix.yml deleted file mode 100644 index 40b244d2a..000000000 --- a/.github/workflows/downstream-format-fix.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Downstream format fix - -on: - push: - branches: - - feat/world-packet-boundary-clean-20260929 - -permissions: - contents: write - -jobs: - format: - if: ${{ github.actor != 'github-actions[bot]' }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - with: - ref: feat/world-packet-boundary-clean-20260929 - - uses: actions/setup-node@v5 - with: - node-version: 24.14.0 - cache: npm - - run: npm ci - env: - PUPPETEER_SKIP_DOWNLOAD: 1 - - run: npm run format - - name: Commit formatting if needed - shell: bash - run: | - if git diff --quiet; then - exit 0 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "chore: apply adopted formatting" - git push From bde1e430efdee543f5a9ff88e341843d8569c2a9 Mon Sep 17 00:00:00 2001 From: Meir Michanie Date: Thu, 24 Sep 2026 20:03:23 +0200 Subject: [PATCH 025/155] 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 284c4d46019c022a4811381c6fbc567f8cf5b229 Mon Sep 17 00:00:00 2001 From: Meir Michanie Date: Thu, 24 Sep 2026 20:03:36 +0200 Subject: [PATCH 026/155] =?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 --° · --°
-