From 7c7563d91b63be493312e3b89e088510274bbe97 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Mon, 21 Sep 2026 10:48:00 +0700 Subject: [PATCH 01/89] fix(gbfs): rate-limit /api/gbfs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy already restricted WHERE it would go — https, an allowlisted host, a station_information/station_status path, no redirects, a 12-second deadline and a 5 MB body cap — but nothing restricted HOW OFTEN, so the dev server would relay an unbounded stream of requests at a third-party bikeshare operator on any caller's behalf, one outbound fetch and one timeout timer each. Admit 120 requests/minute per client address, global backstop 3x, refused with 429 and `Retry-After` before the target is even parsed. The ceiling comes from the layer's own demand: `src/layers/bikeshare/registry.js` catalogues 15 systems, station information is cached per city after its first fetch, and station status refreshes once per city every STATUS_POLL_MS (60s) with in-flight requests deduplicated — about 15 requests a minute with everything in range, so 120 is eight times a maximal session. The method, host, path, redirect, deadline and size guards are unchanged. Refs #264 --- CHANGELOG.md | 9 ++ docs/CURRENT-STATE.md | 9 ++ scripts/format-scope.json | 3 +- scripts/package-boundaries.json | 2 + server/providers/gbfs.js | 34 +++++- src/data/gbfsProxyRateLimit.test.mjs | 152 +++++++++++++++++++++++++++ 6 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 src/data/gbfsProxyRateLimit.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..f1c6ffb53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +- Bound how often the GBFS proxy will relay. `/api/gbfs` already restricted + where it could go — https, an allowlisted host, a station feed path, no + redirects, a 12-second deadline and a 5 MB body cap — but nothing restricted + how often, so the dev server would make unlimited requests at a third-party + bikeshare operator on any caller's behalf. The route now admits 120 + requests/minute per client address, refusing the rest with 429 and + `Retry-After` before any outbound fetch. The bikeshare layer needs about 15 a + minute with every catalogued system in range, so ordinary use is unchanged. + - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..f55945224 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -827,6 +827,15 @@ feed address is read from the path, and a query-string form is refused with a 40 streaming. The 12-second deadline includes reading the body, and rejected or stalled downloads are cancelled. Development and preview use the same handler. +The route also bounds request frequency: 120 requests/minute per client +address, with a global backstop of three times that. A refused request answers +429 with `Retry-After` and `Cache-Control: no-store` and performs no outbound +fetch. The bikeshare layer's own demand is about 15 requests a minute with +every catalogued system in range, so an ordinary session never meets the limit; +the method, host and path guards run unchanged. + +Updated: September 21, 2026 + ## Remaining local service modules Overpass query validation, geometry simplification, disk caching and upstream diff --git a/scripts/format-scope.json b/scripts/format-scope.json index eee546dd5..e7b7a92d0 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -839,5 +839,6 @@ "src/ui/imageryBoxTool.test.mjs", "src/ui/recentImagery.test.mjs", "src/layers/recentImagery/testDoubles.mjs", - "scripts/qa-recent-imagery.mjs" + "scripts/qa-recent-imagery.mjs", + "src/data/gbfsProxyRateLimit.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..1f1db490d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -138,6 +138,8 @@ "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", + "server/providers/common/rate-limit.js", + "src/sources/rateLimit.js", "src/data/gbfsSource.js", "src/sources/httpBody.js" ], diff --git a/server/providers/gbfs.js b/server/providers/gbfs.js index 9741bb4e6..001e94a7d 100644 --- a/server/providers/gbfs.js +++ b/server/providers/gbfs.js @@ -1,4 +1,5 @@ import { readResponseTextCapped } from './common/http.js'; +import { makeRateLimiter, clientKey } from './common/rate-limit.js'; import { isAllowedGbfsHost, isAllowedGbfsPath, @@ -13,6 +14,19 @@ const GBFS_PROXY_TIMEOUT_MS = 12000; export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB +/** + * Requests/minute/IP admitted by the proxy. + * + * The bikeshare layer's own demand is small and known: `src/layers/bikeshare/ + * registry.js` catalogues 15 systems, station information is cached per city + * after its first fetch, and station status refreshes once per city every + * `STATUS_POLL_MS` (60 s) with in-flight requests deduplicated. Even with every + * catalogued system in camera range at once that is about 15 requests a minute, + * so 120 is eight times a maximal real session while still capping a caller + * using this proxy as an open relay against a third-party GBFS host. + */ +export const GBFS_RATE_PER_MIN = 120; + function gbfsRedirectHost(location, requestUrl) { if (!location) return ''; try { @@ -113,7 +127,12 @@ export async function fetchGbfsUpstream( * * @returns {import('vite').Plugin} */ -export function gbfsProxy() { +export function gbfsProxy({ ratePerMinute = GBFS_RATE_PER_MIN } = {}) { + const allow = makeRateLimiter({ + windowMs: 60_000, + max: ratePerMinute, + globalMax: ratePerMinute * 3, + }); const installMiddleware = (server) => { server.middlewares.use('/api/gbfs', async (req, res) => { try { @@ -126,6 +145,19 @@ export function gbfsProxy() { return; } + // Frequency, checked before the target is even parsed: the allowlist + // already says WHERE this proxy may go, nothing said HOW OFTEN. A + // refused request performs no outbound fetch and starts no timer. + if (!allow(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Retry-After': '10', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + const url = new URL(req.url || '/', 'http://localhost'); const encodedTarget = url.pathname.replace(/^\/+/, ''); if (!encodedTarget) { diff --git a/src/data/gbfsProxyRateLimit.test.mjs b/src/data/gbfsProxyRateLimit.test.mjs new file mode 100644 index 000000000..72ee38152 --- /dev/null +++ b/src/data/gbfsProxyRateLimit.test.mjs @@ -0,0 +1,152 @@ +// GBFS PROXY — the frequency the allowlist never bounded. +// +// `/api/gbfs` already says WHERE it may go: an https allowlisted host, a +// station_information/station_status path, a 12 s timeout, a 5 MB body cap and +// no redirects. Nothing said HOW OFTEN, so the dev server would relay an +// unbounded stream of requests at a third-party bikeshare operator on any +// caller's behalf — one outbound fetch and one timeout timer each. +// +// These cases drive the real mounted middleware. `fetch` is stubbed per test +// and restored afterwards, so a case that reaches the network fails loudly, +// and the stub's call count is what proves a refused request costs nothing. +// +// Run with: npm test +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { gbfsProxy, GBFS_RATE_PER_MIN } from '../../server/providers/gbfs.js'; + +const STATUS_URL = 'https://gbfs.lyft.com/gbfs/2.3/bay/en/station_status.json'; +const STATION_BODY = '{"data":{"stations":[]}}'; + +/** Mount the proxy the way the dev server does; returns its route handler. */ +function mount(options) { + const routes = new Map(); + gbfsProxy(options).configureServer({ + middlewares: { + use(route, handler) { + routes.set(route, handler); + }, + }, + }); + return routes.get('/api/gbfs'); +} + +function request(handler, { target = STATUS_URL, remoteAddress, method } = {}) { + return new Promise((resolve, reject) => { + const req = { + method: method || 'GET', + // connect strips the mount prefix before the handler sees the URL. + url: '/' + encodeURIComponent(target), + headers: {}, + socket: remoteAddress ? { remoteAddress } : undefined, + }; + let status = 200; + let headers = {}; + const res = { + writeHead(code, sent) { + status = code; + headers = sent || {}; + return res; + }, + end(body = '') { + let parsed = null; + try { + parsed = body ? JSON.parse(String(body)) : null; + } catch { + parsed = String(body); + } + resolve({ status, headers, body: parsed }); + }, + }; + Promise.resolve(handler(req, res)).catch(reject); + }); +} + +/** Answer every allowlisted upstream, counting the calls that got through. */ +function stubUpstream(t) { + const asked = []; + const original = globalThis.fetch; + globalThis.fetch = async (url) => { + asked.push(String(url)); + return new Response(STATION_BODY, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + t.after(() => { + globalThis.fetch = original; + }); + return asked; +} + +test('a maximal real session is never throttled', async (t) => { + const asked = stubUpstream(t); + const handler = mount(); + + // The bikeshare layer catalogues 15 systems and refreshes each one's status + // once every 60 s, so a minute of the busiest possible session is ~15 + // requests. Run eight times that and expect every one of them served. + let served = 0; + for (let i = 0; i < 120; i += 1) { + const res = await request(handler); + if (res.status === 200) served += 1; + } + assert.equal(served, 120, 'the shipped ceiling leaves a real session alone'); + assert.equal(asked.length, 120); + assert.equal(GBFS_RATE_PER_MIN, 120, 'the ceiling this case relies on'); +}); + +test('an unbounded relay is refused, and refusal costs no outbound fetch', async (t) => { + const asked = stubUpstream(t); + const handler = mount({ ratePerMinute: 5 }); + + const responses = []; + for (let i = 0; i < 12; i += 1) responses.push(await request(handler)); + + const served = responses.filter((r) => r.status === 200); + const refused = responses.filter((r) => r.status === 429); + assert.equal(served.length, 5, 'exactly the admitted budget is served'); + assert.equal(refused.length, 7); + assert.equal(refused[0].body.error, 'Rate limit exceeded'); + assert.equal(refused[0].headers['Retry-After'], '10'); + assert.equal(refused[0].headers['Cache-Control'], 'no-store'); + assert.equal( + asked.length, + 5, + 'a refused request never reaches the GBFS operator', + ); +}); + +test('a different client keeps its own budget', async (t) => { + stubUpstream(t); + const handler = mount({ ratePerMinute: 3 }); + + for (let i = 0; i < 6; i += 1) + await request(handler, { remoteAddress: '10.0.0.1' }); + const noisy = await request(handler, { remoteAddress: '10.0.0.1' }); + assert.equal(noisy.status, 429, 'the noisy client spent its own budget'); + + const quiet = await request(handler, { remoteAddress: '10.0.0.2' }); + assert.equal(quiet.status, 200, 'a quiet client is not collateral'); +}); + +test('the limiter does not displace the existing guards', async (t) => { + const asked = stubUpstream(t); + const handler = mount({ ratePerMinute: 10 }); + + const wrongMethod = await request(handler, { method: 'POST' }); + assert.equal(wrongMethod.status, 405); + + const offAllowlist = await request(handler, { + target: 'https://example.com/gbfs/station_status.json', + }); + assert.equal(offAllowlist.status, 403); + assert.equal(offAllowlist.body.error, 'GBFS host not allowed'); + + const wrongPath = await request(handler, { + target: 'https://gbfs.lyft.com/gbfs/2.3/bay/en/system_regions.json', + }); + assert.equal(wrongPath.status, 400); + + assert.equal(asked.length, 0, 'none of those reached upstream'); +}); From 936d75b69e89b2d5005db060a06f8d2f18d3ef01 Mon Sep 17 00:00:00 2001 From: daikaginza Date: Wed, 23 Sep 2026 16:45:08 +0700 Subject: [PATCH 02/89] 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 03/89] 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 04/89] 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 bde1e430efdee543f5a9ff88e341843d8569c2a9 Mon Sep 17 00:00:00 2001 From: Meir Michanie Date: Thu, 24 Sep 2026 20:03:23 +0200 Subject: [PATCH 05/89] 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 06/89] =?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 --° · --°
-