Merge branch 'main' into upstream/voice-alias-hint-budget

This commit is contained in:
quanticsoul4772 authored and GitHub committed 2026-10-29 06:13:18 -07:00
commit 3772aaed0a
21 files changed
+3596 -16

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
- Add Ukrainian (uk) to the language picker: the whole interface in Ukrainian,
with its four plural forms and Cyrillic HUD units. Based on the Ukrainian
translation by @meirm (#312) (German, @gekh, #1470).
+7
View File
@@ -0,0 +1,7 @@
- Let the scene idle with AIS vessels enabled. Vessels no longer hold the
continuous render loop; they request frames for new data and selection, one
trailing frame after the camera stops, and hold the loop only while focus
emphasis animates or a vessel stays dimmed under a tracked target. A parked
vessels-only scene went from continuous rendering to 0 frames in 5 s. While
anything is tracked, the vessel focus pass skips hidden sprites at rest:
about 4 ms to 1.2 ms per pass over 12,000 vessels (waar19, #871).
+11 -1
View File
@@ -2286,6 +2286,16 @@ fetching, trailing-24-hour filtering and partial-success caching are unchanged.
> SCOPE toggle + FEATHER slider, hash keys `sc`/`scf`) — it is NOT the > SCOPE toggle + FEATHER slider, hash keys `sc`/`scf`) — it is NOT the
> six zero-intensity style stages anymore (those are disabled; see the > six zero-intensity style stages anymore (those are disabled; see the
> history note in `_initStages`). Hidden tabs stop the render loop. > history note in `_initStages`). Hidden tabs stop the render loop.
> AIS vessels no longer hold the loop while enabled (2026-09-30): vessels move
> only when a poll lands, so the layer requests a frame for every forced pass
> (new data, selection), leaves one trailing frame when the camera moved inside
> the 800 ms visibility cadence, and holds `ais-vessels` only while focus
> emphasis animates or any vessel stays dimmed under a live focus target. The
> trailing frame goes through the layer's injectable runtime clock and is
> cancelled on disable, destroy and reset (`renderGovernor.test.mjs` pins the
> no-self-wake invariant). The 80 ms focus pass also skips hidden sprites whose
> focus state is at rest (`spriteFocusAtRest`), since advancing them changes
> nothing; with a 12k worldwide feed that is most of the fleet.
> The scope's OUTSIDE terminus is **altitude-adaptive** (2026-08-17): 0.94 > The scope's OUTSIDE terminus is **altitude-adaptive** (2026-08-17): 0.94
> at/above **10 Mm**, so > at/above **10 Mm**, so
> faint stars survive in the corners of a TRUE full-globe view, fading quickly > faint stars survive in the corners of a TRUE full-globe view, fading quickly
@@ -3772,7 +3782,7 @@ test:track` 43 tracking invariants · headless QA harnesses under
### Interface localization (October 2026) ### Interface localization (October 2026)
Shipped locales: **en** (source and fallback), **es**, **ru**, **zh-CN** and **zh-Hant**. The code lives in Shipped locales: **en** (source and fallback), **es**, **ru**, **uk**, **zh-CN** and **zh-Hant**. The code lives in
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow: `src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
[`docs/TRANSLATORS.md`](./TRANSLATORS.md). [`docs/TRANSLATORS.md`](./TRANSLATORS.md).
+2 -2
View File
@@ -1,6 +1,6 @@
# Translating God's Eye View # Translating God's Eye View
Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`). Shipped languages: **English** (source and fallback), **Spanish** (`es`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
## How it works ## How it works
@@ -63,4 +63,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
## Language notes ## Language notes
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one. Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Spanish](./translations/es.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
+106
View File
@@ -0,0 +1,106 @@
# Ukrainian (`uk`)
Catalog: `src/i18n/locales/uk.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
Translation source: meirm (#312).
## Style
- Address the user with polite imperatives: «Натисніть», «Увімкніть», «Спробуйте ще раз».
- Sentence case, not Title Case: "Live Flights" → «Рейси онлайн».
- Ukrainian norms, not Russian calques: «у радіусі», «за посиланням», «надто довгий», «потім», «вигляд» (a camera view: «вигляд згори»), «стеження» (tracking); prepositions alternate у/в and і/й by euphony.
- The apostrophe is always ʼ (U+02BC): обʼєкт, звʼязок, памʼять. Not ' or ’.
- Quotes inside Ukrainian text are «ялинки»; a button or layer name inside a sentence goes in them: Увімкніть «Вуличні знімки».
- In short labels, a standard abbreviation with a period beats a wrapped line: ПОПЕР., ДАЛЬН., КАЛІБР.
- Units: км, м, км/год, м/с, вуз (knots), фт (feet), год, хв, с, МГц, дБ, гПа; in UPPERCASE text КМ, М, КМ/С, ВУЗ, ФТ. MW is МВт in any case. `°` and `%` stay. Decimal comma in coordinates written as text: 28,2710° пн. ш.
- Coordinates: пн. ш. / пд. ш. / сх. д. / зх. д.; the HUD hemisphere letters are Пн, Пд, Сх, Зх.
## Plurals
A `tn()` entry needs four forms. `Intl.PluralRules('uk')` picks:
| Form | Counts | Example |
| ------- | ------------------------ | ---------- |
| `one` | 1, 21, 31, 101… | 1 камера |
| `few` | 2–4, 22–24, 32–34… | 3 камери |
| `many` | 0, 5–20, 25–30, 111–114… | 5 камер |
| `other` | fractions: 1.5, 2.25… | 1,5 камери |
`other` takes the genitive singular: 1,5 кадру, 1,5 літака, 1,5 ключа. When a word next to a number cannot agree in every form, move the number after a colon instead: «поруч: {count}», «відповідей: {responses}». The same trick keeps place names and tool names out of a preposition's case: «звідки: {origin}», «Активно: {name}».
## Glossary
| English | Українська |
| ----------------------------------------------- | ---------------------------------------------------------------- |
| layer / data layer | шар / шар даних |
| feed | потік даних (short: потік) |
| source / data source | джерело / джерело даних |
| LIVE (status) / live data | ОНЛАЙН / у реальному часі (status chip: ДАНІ ОНЛАЙН) |
| SIMULATED / SYNTHETIC | ІМІТАЦІЯ |
| stale / degraded / unavailable | застарів / з перебоями (chip: ПЕРЕБОЇ) / недоступний |
| retry | повтор |
| key (API key) / POWER UP | ключ / ЗАПУСК |
| Provider Settings / provider | Налаштування постачальників / постачальник |
| globe / map / map source / basemap | глобус / карта / джерело карти / підкладка |
| aircraft / flight / military flights | літак (short HUD: борт) / рейс / військові рейси |
| vessel, ship / vessels | судно / судна (gen. pl. суден) |
| satellite | супутник |
| launch (rocket) / space mission | запуск / космічна місія |
| ascent (rocket) / booster FLIGHT {n} | виведення / ПОЛІТ {n} (not рейс) |
| contact / tracking | контакт / стеження |
| installation, site / mapped | обʼєкт / нанесений (обʼєкт, контакт) |
| camera / CCTV camera | камера / камера CCTV |
| earthquake / active fire / fire perimeter | землетрус / активна пожежа / контур пожежі |
| advisory (NHC/CPHC cyclone) | зведення |
| scene / shot / scene recipe | сцена / кадр / рецепт сцени |
| briefing / panel / dock | брифінг / панель / док |
| preset / visual preset | пресет / візуальний пресет |
| Scope, keyhole (circular viewport mask) | приціл |
| overlay (detection overlay) / world overlay | накладка / накладка на глобусі |
| heading / altitude / ground speed | курс / висота / шляхова швидкість |
| map bearing, map heading | азимут карти |
| HDG / BRG / CRS, TRK / FOV / DEST / SPD / RANGE | КУРС / ПЕЛ. / ШК (шляховий кут) / ОГЛЯД / ПРИЗН. / ШВИД / ДАЛЬН. |
| Street View (Google) | Перегляд вулиць (as in Ukrainian Google Maps) |
| Street Level (layer) | Вуличні знімки |
| imagery / recent imagery | знімки / свіжі знімки |
| radar reflectivity / lightning density | радіолокаційна відбивність / щільність блискавок |
| infrared | інфрачервоний (short: ІЧ) |
| receiver / gain / decoder | приймач / підсилення / декодер |
| broadcaster | мовник |
| CONUS, WHOT/BHOT, Ironbow, CRT, NVG, FLIR | без перекладу |
| PREV / NEXT (HUD) / Previous / Next | ПОПЕР. / ДАЛІ / Попередній / Наступний |
| anchor / data pack / bundle (scene) | якір / пакет даних / архів |
| box (Recent Imagery area) | область |
| camera move | переліт |
| SIG / NEWS / LOCAL (briefing tabs) | СИГН. / НОВИНИ / МІСЦ. |
| compass points (N, NNE, NE, …) | Пн, ПнПнСх, ПнСх, … |
| L / R (relative bearing) | Л / П |
| click / tap | клацнути / натиснути |
| session (voice, OAuth) | сеанс |
| sequence (street-level photos) | послідовність |
| search (place search) | пошук місця |
| pitch (CCTV camera pose) | нахил |
| traffic (road traffic layer) | дорожній рух |
| phosphor (CRT) | люмінофор |
| view (map extent) / view centre / in view | видима область (карти) / центр карти / у кадрі |
| select (verb) / selected | вибрати / вибраний (not обрати) |
| ping (cockpit proximity contacts) | контакт |
| merge (turn-by-turn) / on ramp | влитися в потік / вʼїзд (off ramp: зʼїзд) |
| watch (Earth/Orbital Watch) | спостереження |
| timeout | тайм-аут |
| icon | значок (not іконка) |
| run (scene playback) / RUN LOG | відтворення / ЖУРНАЛ ВІДТВОРЕННЯ (запуск is a rocket launch) |
| Preview (voice panel replies) | відповіді |
| Marine (radio tag) / pleasure craft | море (not флот) / прогулянкове судно |
| emergency (ADS-B) / Emergency (radio tag) | аварійний стан / надзвичайні ситуації |
| confidence (FIRMS fire detection) | достовірність (short: достов.) |
| thermal (FLIR-style imaging) | тепловий (not термальний) |
| map stack | набір карт |
| stage (rocket) re-entry / recovery | вхід ступеня в атмосферу / повернення |
| ON / OFF (toggle chips) | УВІМК. / ВИМК. |
| Clean UI / EXIT CLEAN VIEW | чистий інтерфейс / ПОВЕРНУТИ ІНТЕРФЕЙС |
| full globe view | вигляд всього глобуса |
| inputs (cockpit context chip) | потоки (not вхідні дані) |
| downrange (rocket stage) | дальність |
| resolution (spatial) | роздільна здатність (not роздільність) |
| shared follow expired | посилання на стеження застаріло |
+43
View File
@@ -441,6 +441,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -481,6 +482,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -511,6 +513,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
@@ -533,6 +536,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -550,6 +554,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -570,6 +575,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
@@ -597,6 +603,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/layerNames.js" "src/ui/layerNames.js"
@@ -632,6 +639,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
@@ -649,6 +657,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
@@ -808,6 +817,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
@@ -930,6 +940,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
@@ -998,6 +1009,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
@@ -1047,6 +1059,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -1110,6 +1123,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -1140,6 +1154,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
@@ -1171,6 +1186,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -1202,6 +1218,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
@@ -1236,6 +1253,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -1279,6 +1297,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/layers/installations/classLabel.js" "src/layers/installations/classLabel.js"
@@ -1316,6 +1335,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
@@ -1367,6 +1387,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -1397,6 +1418,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -1446,6 +1468,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -1510,6 +1533,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/layers/streetLevel/providers/mapillary/policy.js" "src/layers/streetLevel/providers/mapillary/policy.js"
@@ -1540,6 +1564,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -1581,6 +1606,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -1604,6 +1630,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -1661,6 +1688,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -1724,6 +1752,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js", "src/ui/statusText.js",
@@ -2608,6 +2637,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
@@ -3193,6 +3223,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js", "src/ui/unitLabels.js",
@@ -3304,6 +3335,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/i18n/index.js" "src/i18n/index.js"
@@ -3328,6 +3360,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -3365,6 +3398,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/scopeMaskPolicy.js", "src/scopeMaskPolicy.js",
@@ -3392,6 +3426,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -3484,6 +3519,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
@@ -3540,6 +3576,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js", "src/ui/statusText.js",
@@ -3795,6 +3832,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -3839,6 +3877,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -3893,6 +3932,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json"
], ],
@@ -3910,6 +3950,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js", "src/ui/statusText.js",
@@ -3970,6 +4011,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/data/geoImportImages.js", "src/data/geoImportImages.js",
@@ -4167,6 +4209,7 @@
"src/i18n/locale.js", "src/i18n/locale.js",
"src/i18n/locales/es.json", "src/i18n/locales/es.json",
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js", "src/ui/statusText.js",
+47
View File
@@ -1719,3 +1719,50 @@ test('vessel selection passes the opaque source reference to optional history',
aisLiveVesselsLayer.setSource(createVesselSource()); aisLiveVesselsLayer.setSource(createVesselSource());
} }
}); });
test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => {
const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) });
const sprite = (show) => ({
position: { x: 1, y: 2, z: 3 },
show,
color: makeColor(1),
});
const visible = sprite(true);
const hidden = Array.from({ length: 50 }, () => sprite(false));
const records = [visible, ...hidden].map((billboard) => ({
position: billboard.position,
billboard,
}));
const target = {
screenRect: { left: 40, top: 40, right: 60, bottom: 60 },
paddingPx: 0,
cameraDistance: 1000,
};
let projected = 0;
let measured = 0;
const apply = (nowMs) =>
applyVesselFocusDeemphasis({
records,
target,
nowMs,
screenPositionFor: () => {
projected += 1;
return { x: 50, y: 50 };
},
cameraDistanceFor: () => {
measured += 1;
return 1200;
},
params: { paddingPx: 0, dimFloor: 0.25, attackMs: 300 },
});
apply(0);
const result = apply(300);
// Only the on-screen sprite pays for projection and distance.
assert.equal(projected, 2);
assert.equal(measured, 2);
assert.equal(visible.color.alpha, 0.25);
assert.equal(result.activeCount, 1);
for (const billboard of hidden) assert.equal(billboard.color.alpha, 1);
});
+15
View File
@@ -555,6 +555,21 @@ export function advanceFocusEvidenceNowMs(deltaMs) {
return _focusEvidenceNowMs; return _focusEvidenceNowMs;
} }
/**
* True when a sprite holds no dimming state, or has settled at full emphasis
* with nothing pending. Advancing such a sprite without a screen position (a
* hidden sprite) changes neither its state nor its alpha, so a layer pass may
* skip it.
* @param {object} sprite
* @returns {boolean}
*/
export function spriteFocusAtRest(sprite) {
const state = _spriteStates.get(sprite);
return (
!state || (state.factor === 1 && state.desired === 1 && !state.wasFarther)
);
}
/** Forget state when a primitive is removed from its owning collection. */ /** Forget state when a primitive is removed from its owning collection. */
export function forgetSpriteFocus(sprite) { export function forgetSpriteFocus(sprite) {
if (sprite) _spriteStates.delete(sprite); if (sprite) _spriteStates.delete(sprite);
+18
View File
@@ -33,6 +33,8 @@ function memoryStorage(initial = {}) {
test('normalizeLocale folds regional variants and rejects unshipped tags', () => { test('normalizeLocale folds regional variants and rejects unshipped tags', () => {
assert.equal(normalizeLocale('ru-RU'), 'ru'); assert.equal(normalizeLocale('ru-RU'), 'ru');
assert.equal(normalizeLocale(' RU '), 'ru'); assert.equal(normalizeLocale(' RU '), 'ru');
assert.equal(normalizeLocale('uk'), 'uk');
assert.equal(normalizeLocale('uk-UA'), 'uk');
assert.equal(normalizeLocale('en_GB'), 'en'); assert.equal(normalizeLocale('en_GB'), 'en');
assert.equal(normalizeLocale('xx'), null); assert.equal(normalizeLocale('xx'), null);
// Explicit script tags take precedence over regional defaults. // Explicit script tags take precedence over regional defaults.
@@ -142,6 +144,22 @@ test('tn() picks English and Russian plural forms', () => {
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/); assert.match(label(21), / 21 КЛЮЧ(?![А-ЯЁ])/);
}); });
test('tn() picks the four Ukrainian plural forms', () => {
const label = (count) =>
tn(
count,
'POWER UP · {count} KEY WAITING',
'POWER UP · {count} KEYS WAITING',
);
setLocale('uk');
assert.match(label(1), / 1 КЛЮЧ(?![А-ЯҐЄІЇ])/);
assert.match(label(3), / 3 КЛЮЧІ(?![А-ЯҐЄІЇ])/);
assert.match(label(5), / 5 КЛЮЧІВ(?![А-ЯҐЄІЇ])/);
assert.match(label(11), / 11 КЛЮЧІВ(?![А-ЯҐЄІЇ])/);
assert.match(label(21), / 21 КЛЮЧ(?![А-ЯҐЄІЇ])/);
assert.match(label(1.5), / 1\.5 КЛЮЧА(?![А-ЯҐЄІЇ])/);
});
test('t() context separates identical English with different meanings', () => { test('t() context separates identical English with different meanings', () => {
assert.equal(t('CLEAR', { context: 'action' }), 'CLEAR'); assert.equal(t('CLEAR', { context: 'action' }), 'CLEAR');
setLocale('ru'); setLocale('ru');
+8 -1
View File
@@ -18,10 +18,17 @@ import {
} from './locale.js'; } from './locale.js';
import es from './locales/es.json' with { type: 'json' }; import es from './locales/es.json' with { type: 'json' };
import ru from './locales/ru.json' with { type: 'json' }; import ru from './locales/ru.json' with { type: 'json' };
import uk from './locales/uk.json' with { type: 'json' };
import zhCN from './locales/zh-CN.json' with { type: 'json' }; import zhCN from './locales/zh-CN.json' with { type: 'json' };
import zhHant from './locales/zh-Hant.json' with { type: 'json' }; import zhHant from './locales/zh-Hant.json' with { type: 'json' };
const CATALOGS = Object.freeze({ es, ru, 'zh-CN': zhCN, 'zh-Hant': zhHant }); const CATALOGS = Object.freeze({
es,
ru,
uk,
'zh-CN': zhCN,
'zh-Hant': zhHant,
});
let currentLocale = DEFAULT_LOCALE; let currentLocale = DEFAULT_LOCALE;
+9 -1
View File
@@ -8,13 +8,21 @@ export const LOCALE_STORAGE_KEY = 'gev:locale:v1';
export const DEFAULT_LOCALE = 'en'; export const DEFAULT_LOCALE = 'en';
/** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */ /** Shipped locales; order does not matter. Adding one: docs/TRANSLATORS.md. */
export const LOCALES = Object.freeze(['en', 'es', 'ru', 'zh-CN', 'zh-Hant']); export const LOCALES = Object.freeze([
'en',
'es',
'ru',
'uk',
'zh-CN',
'zh-Hant',
]);
/** Each locale named in its own language, for the selector. */ /** Each locale named in its own language, for the selector. */
export const LOCALE_NAMES = Object.freeze({ export const LOCALE_NAMES = Object.freeze({
en: 'English', en: 'English',
es: 'Español', es: 'Español',
ru: 'Русский', ru: 'Русский',
uk: 'Українська',
'zh-CN': '中文(简体)', 'zh-CN': '中文(简体)',
'zh-Hant': '中文(繁體)', 'zh-Hant': '中文(繁體)',
}); });
File diff suppressed because it is too large. Load diff
+13
View File
@@ -142,6 +142,19 @@
"VS", "VS",
"WHOT/BHOT" "WHOT/BHOT"
], ],
"uk": [
"AIS: --",
"AIS: {name}",
"CCTV",
"CRT",
"FLIR",
"Ironbow",
"Landsat 8/9",
"NVG",
"REC",
"S2 GhostMaps",
"WHOT/BHOT"
],
"zh-CN": [ "zh-CN": [
"1090 MHz", "1090 MHz",
"978 MHz UAT", "978 MHz UAT",
+4 -2
View File
@@ -11,7 +11,7 @@ export function createLifecycle({
}) { }) {
const { state } = vesselState; const { state } = vesselState;
const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites; const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites;
const { holdContinuousRender, releaseContinuousRender } = services.render; const { releaseContinuousRender } = services.render;
const { ensureGeoidReady } = services.geoid; const { ensureGeoidReady } = services.geoid;
const { registerPickOwner, unregisterPickOwner } = services.picking; const { registerPickOwner, unregisterPickOwner } = services.picking;
@@ -125,6 +125,7 @@ export function createLifecycle({
state.interactionHandlerFactory = null; state.interactionHandlerFactory = null;
state.interactionKeyTarget = null; state.interactionKeyTarget = null;
state.preRenderRemover = null; state.preRenderRemover = null;
components.rendering.cancelTrailingVisibilityPass();
state.lastVisibilityUpdate = 0; state.lastVisibilityUpdate = 0;
state.lastFocusUpdate = 0; state.lastFocusUpdate = 0;
state.activeFocusCount = 0; state.activeFocusCount = 0;
@@ -164,7 +165,6 @@ export function createLifecycle({
const wasEnabled = state.feed.enabled; const wasEnabled = state.feed.enabled;
state.feed.enabled = true; state.feed.enabled = true;
if (!wasEnabled) beginAisSession(); if (!wasEnabled) beginAisSession();
holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2)
const activeViewer = viewer || state.viewer; const activeViewer = viewer || state.viewer;
components.rendering.ensureCollections(activeViewer); components.rendering.ensureCollections(activeViewer);
components.selection.installInteraction(activeViewer); components.selection.installInteraction(activeViewer);
@@ -208,6 +208,7 @@ export function createLifecycle({
state.feed.enabled = false; state.feed.enabled = false;
invalidateAisSession(); invalidateAisSession();
releaseContinuousRender('ais-vessels'); releaseContinuousRender('ais-vessels');
components.rendering.cancelTrailingVisibilityPass();
unregisterPickOwner('ais-live-vessels'); unregisterPickOwner('ais-live-vessels');
components.rendering.setVisible(false); components.rendering.setVisible(false);
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
@@ -227,6 +228,7 @@ export function createLifecycle({
const activeViewer = viewer || state.viewer; const activeViewer = viewer || state.viewer;
invalidateAisSession(); invalidateAisSession();
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix) releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
components.rendering.cancelTrailingVisibilityPass();
if (state.feed.abort) state.feed.abort.abort(); if (state.feed.abort) state.feed.abort.abort();
unregisterPickOwner('ais-live-vessels'); unregisterPickOwner('ais-live-vessels');
components.selection.clearVesselInspection(); components.selection.clearVesselInspection();
+5 -1
View File
@@ -29,7 +29,11 @@ function services() {
forgetSpriteFocus: noop, forgetSpriteFocus: noop,
}, },
worldFocus: {}, worldFocus: {},
render: { releaseContinuousRender: noop }, render: {
holdContinuousRender: noop,
releaseContinuousRender: noop,
governorRequestRender: noop,
},
}; };
} }
function setup(source, options = {}) { function setup(source, options = {}) {
+138
View File
@@ -0,0 +1,138 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createVesselLayer } from './index.js';
import { VISIBILITY_UPDATE_MS } from './policy.js';
// Vessels move only when a poll lands, so the layer must let the scene idle:
// it asks for frames for forced passes and one trailing frame after the camera
// stops, and never schedules a frame from a pass the camera did not cause.
const noop = () => {};
function setup() {
let clock = 10_000;
const requests = [];
const holds = new Set();
const timers = new Map();
let nextTimer = 1;
const layer = createVesselLayer({
source: { getSnapshot: async () => ({ records: [] }) },
services: {
context: {
clearSelectedEntityContextForLayer: noop,
registerEntityContext: () => null,
},
trails: {
createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }),
},
labels: {},
picking: { unregisterPickOwner: noop },
overlay: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
geoid: {},
sprites: {},
focus: {
focusNowMs: () => clock,
getFocusTarget: () => null,
focusPassIsNeeded: () => false,
forgetSpriteFocus: noop,
},
worldFocus: {},
render: {
holdContinuousRender: (id) => holds.add(id),
releaseContinuousRender: (id) => holds.delete(id),
governorRequestRender: (reason) => requests.push(reason),
},
},
});
const camera = {
positionWC: { x: 0, y: 0, z: 7_000_000 },
heading: 0,
pitch: -1.2,
roll: 0,
};
layer.testing._setVesselStateForTest({ viewer: { camera, scene: {} } });
layer.testing._setAisRuntimeForTest({
now: () => clock,
setTimeout(fn, ms) {
const id = nextTimer++;
timers.set(id, { fn, at: clock + ms });
return id;
},
clearTimeout: (id) => timers.delete(id),
});
const advance = (ms) => {
clock += ms;
for (const [id, timer] of [...timers]) {
if (timer.at > clock) continue;
timers.delete(id);
timer.fn();
}
};
const frame = () => layer.testing._runVisibilityPassForTest();
return { layer, camera, requests, holds, timers, advance, frame };
}
test('a parked camera schedules no frames after the cadence pass', () => {
const { requests, holds, timers, advance, frame } = setup();
frame();
for (let i = 0; i < 20; i += 1) {
advance(100);
frame();
}
assert.deepEqual(requests, []);
assert.equal(timers.size, 0);
assert.equal(holds.has('ais-vessels'), false);
});
test('a camera that stops inside the cadence gets exactly one trailing frame', () => {
const { layer, camera, requests, timers, advance, frame } = setup();
frame();
advance(100);
camera.heading = 0.5;
frame();
advance(100);
frame();
assert.equal(timers.size, 1, 'one trailing frame, not one per skipped pass');
assert.equal(layer.testing._hasTrailingVisibilityPassForTest(), true);
advance(VISIBILITY_UPDATE_MS);
assert.deepEqual(requests, ['ais-vessels-visibility']);
// The trailing frame runs the regular pass on the pose the camera stopped
// on. Its own frames must not schedule another one.
frame();
for (let i = 0; i < 20; i += 1) {
advance(100);
frame();
}
assert.deepEqual(requests, ['ais-vessels-visibility']);
assert.equal(timers.size, 0);
});
test('a forced pass requests its own frame', () => {
const { layer, requests } = setup();
layer.testing._runVisibilityPassForTest(true);
assert.deepEqual(requests, ['ais-vessels-update']);
});
test('destroy cancels a pending trailing frame', (t) => {
const originalDocument = globalThis.document;
globalThis.document = { getElementById: () => null };
t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
});
const { layer, camera, requests, timers, advance, frame } = setup();
frame();
advance(100);
camera.heading = 0.5;
frame();
assert.equal(timers.size, 1);
layer.destroy();
assert.equal(timers.size, 0);
advance(VISIBILITY_UPDATE_MS);
assert.deepEqual(requests, []);
});
+66 -2
View File
@@ -75,7 +75,13 @@ export function createRendering({
focusPassIsNeeded, focusPassIsNeeded,
advanceSpriteFocus, advanceSpriteFocus,
focusAlphaNeedsWrite, focusAlphaNeedsWrite,
spriteFocusAtRest,
} = services.focus; } = services.focus;
const {
holdContinuousRender,
releaseContinuousRender,
governorRequestRender,
} = services.render;
function renderRowLimit() { function renderRowLimit() {
const configured = Number(options.maxRows); const configured = Number(options.maxRows);
@@ -196,6 +202,14 @@ export function createRendering({
return icon; return icon;
} }
// Vessels move only when a poll lands, so the layer does not hold the
// continuous render loop. Its passes run in preRender, which idle mode fires
// only for rendered frames: a forced pass (new data, selection) requests its
// frame, a frame that moved the camera but fell inside the 800 ms cadence
// leaves a trailing frame so the view the camera stopped on is always
// evaluated, and the continuous hold is kept only while focus emphasis is
// animating. The trailing frame is keyed to camera motion alone: the pass's
// own label publication requests a frame, and scheduling on that would loop.
function installRuntime(viewer) { function installRuntime(viewer) {
if (state.preRenderRemover || !viewer) return; if (state.preRenderRemover || !viewer) return;
state.preRenderRemover = viewer.scene.preRender.addEventListener(() => state.preRenderRemover = viewer.scene.preRender.addEventListener(() =>
@@ -203,8 +217,27 @@ export function createRendering({
); );
} }
function scheduleTrailingVisibilityPass(now) {
if (state.trailingVisibilityTimer) return;
const waitMs = Math.max(
0,
VISIBILITY_UPDATE_MS - (now - state.lastVisibilityUpdate),
);
state.trailingVisibilityTimer = vesselState._aisRuntime.setTimeout(() => {
state.trailingVisibilityTimer = null;
if (state.feed.enabled) governorRequestRender('ais-vessels-visibility');
}, waitMs);
}
function cancelTrailingVisibilityPass() {
if (!state.trailingVisibilityTimer) return;
vesselState._aisRuntime.clearTimeout(state.trailingVisibilityTimer);
state.trailingVisibilityTimer = null;
}
function updateVisibility(force = false) { function updateVisibility(force = false) {
if (!state.feed.enabled) return; if (!state.feed.enabled) return;
if (force) governorRequestRender('ais-vessels-update');
const now = focusNowMs(performance.now()); const now = focusNowMs(performance.now());
const focusTarget = getFocusTarget(); const focusTarget = getFocusTarget();
const regularPass = const regularPass =
@@ -212,13 +245,30 @@ export function createRendering({
const focusPass = const focusPass =
focusPassIsNeeded(focusTarget, state.activeFocusCount) && focusPassIsNeeded(focusTarget, state.activeFocusCount) &&
(force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS); (force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS);
if (
!regularPass &&
state.viewer?.camera &&
cameraPoseSignature(state.viewer.camera) !== vesselState._lastCamPoseSig
)
scheduleTrailingVisibilityPass(now);
if (!regularPass && !focusPass) return; if (!regularPass && !focusPass) return;
if (regularPass) state.lastVisibilityUpdate = now; if (regularPass) state.lastVisibilityUpdate = now;
if (focusPass) state.lastFocusUpdate = now; if (focusPass) state.lastFocusUpdate = now;
if (!state.records.all.length) { if (!state.records.all.length) {
// No records — flush any lingering card entries (vanished-feed case). // No records — flush any lingering card entries (vanished-feed case).
if (regularPass) updateClusteredLabels([]); // Still record the pose this pass evaluated, or every later frame that
if (focusPass) state.activeFocusCount = 0; // falls inside the cadence would schedule another trailing frame.
if (regularPass) {
if (state.viewer?.camera)
vesselState._lastCamPoseSig = cameraPoseSignature(
state.viewer.camera,
);
updateClusteredLabels([]);
}
if (focusPass) {
state.activeFocusCount = 0;
releaseContinuousRender('ais-vessels');
}
return; return;
} }
@@ -272,6 +322,9 @@ export function createRendering({
Cesium.Cartesian3.distance(camera.positionWC, position), Cesium.Cartesian3.distance(camera.positionWC, position),
}); });
state.activeFocusCount = result.activeCount; state.activeFocusCount = result.activeCount;
if (result.activeCount > 0 || result.transitioning)
holdContinuousRender('ais-vessels');
else releaseContinuousRender('ais-vessels');
} }
} }
@@ -302,6 +355,16 @@ export function createRendering({
const bb = visual?.billboard; const bb = visual?.billboard;
const position = bb?.position || visual?.position; const position = bb?.position || visual?.position;
if (!bb || !position) continue; if (!bb || !position) continue;
// Most of a worldwide feed sits behind the horizon. A hidden sprite with
// no pending focus state and full alpha would advance to exactly what it
// already is, so skip it instead of paying its distance and state work
// every 80 ms while anything is tracked.
if (
bb.show === false &&
spriteFocusAtRest(bb) &&
!focusAlphaNeedsWrite(bb.color?.alpha, 1, params)
)
continue;
const focus = advanceSpriteFocus(bb, { const focus = advanceSpriteFocus(bb, {
// Hidden/far-side sprites still finish any pending release so the active // Hidden/far-side sprites still finish any pending release so the active
// count remains truthful and they cannot reappear with stale dim alpha. // count remains truthful and they cannot reappear with stale dim alpha.
@@ -510,6 +573,7 @@ export function createRendering({
shipIcon, shipIcon,
installRuntime, installRuntime,
updateVisibility, updateVisibility,
cancelTrailingVisibilityPass,
applyVesselFocusDeemphasis, applyVesselFocusDeemphasis,
makeOccluder, makeOccluder,
isVisible, isVisible,
+2
View File
@@ -64,6 +64,8 @@ export function createVesselState({ source, services }) {
/** Test-only key target paired with interactionHandlerFactory. */ /** Test-only key target paired with interactionHandlerFactory. */
interactionKeyTarget: null, interactionKeyTarget: null,
preRenderRemover: null, preRenderRemover: null,
/** Pending timer that renders one frame after a cadence-skipped pass. */
trailingVisibilityTimer: null,
lastVisibilityUpdate: 0, lastVisibilityUpdate: 0,
lastFocusUpdate: 0, lastFocusUpdate: 0,
/** Sprites whose animated emphasis remains outside the 1.0 deadband. */ /** Sprites whose animated emphasis remains outside the 1.0 deadband. */
+14
View File
@@ -151,6 +151,18 @@ export function createTesting({
}; };
} }
/** Run one preRender visibility/focus pass. Test-only seam. */
function _runVisibilityPassForTest(force = false) {
components.rendering.updateVisibility(force);
}
/** True while a trailing visibility frame is scheduled. Test-only seam. */
function _hasTrailingVisibilityPassForTest() {
return state.trailingVisibilityTimer !== null;
}
/** /**
* Read lifecycle ownership state without exposing the mutable state object. * Read lifecycle ownership state without exposing the mutable state object.
* Test-only seam. * Test-only seam.
@@ -176,5 +188,7 @@ export function createTesting({
_setAisRuntimeForTest, _setAisRuntimeForTest,
_getVesselFeedStateForTest, _getVesselFeedStateForTest,
_getVesselStateForTest, _getVesselStateForTest,
_runVisibilityPassForTest,
_hasTrailingVisibilityPassForTest,
}; };
} }
+6 -5
View File
@@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES, nativeNames: LOCALE_NAMES,
}), }),
), ),
['ru', 'en', 'es', 'zh-CN', 'zh-Hant'], ['ru', 'uk', 'en', 'es', 'zh-CN', 'zh-Hant'],
); );
assert.deepEqual( assert.deepEqual(
codes( codes(
@@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES, nativeNames: LOCALE_NAMES,
}), }),
), ),
['es', 'en', 'ru', 'zh-CN', 'zh-Hant'], ['es', 'en', 'ru', 'uk', 'zh-CN', 'zh-Hant'],
); );
assert.deepEqual( assert.deepEqual(
codes( codes(
@@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => {
nativeNames: LOCALE_NAMES, nativeNames: LOCALE_NAMES,
}), }),
), ),
['en', 'es', 'ru', 'zh-CN', 'zh-Hant'], ['en', 'es', 'ru', 'uk', 'zh-CN', 'zh-Hant'],
); );
const rows = buildLocaleOptions({ const rows = buildLocaleOptions({
locales: MANY, locales: MANY,
@@ -261,7 +261,8 @@ test('keyboard model: open on the current language, move, choose, close', () =>
model.openList(); model.openList();
assert.equal(model.activeOption.code, 'ru'); assert.equal(model.activeOption.code, 'ru');
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true }); assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
assert.equal(model.activeOption.code, 'en'); // Russian collation puts the other Cyrillic name next.
assert.equal(model.activeOption.code, 'uk');
model.keydown({ key: 'End' }); model.keydown({ key: 'End' });
assert.equal(model.activeOption.code, 'zh-Hant'); assert.equal(model.activeOption.code, 'zh-Hant');
model.keydown({ key: 'Home' }); model.keydown({ key: 'Home' });
@@ -275,7 +276,7 @@ test('keyboard model: open on the current language, move, choose, close', () =>
model.keydown({ key: 'ArrowDown' }); model.keydown({ key: 'ArrowDown' });
assert.deepEqual(model.keydown({ key: ' ' }), { assert.deepEqual(model.keydown({ key: ' ' }), {
handled: true, handled: true,
choose: 'en', choose: 'uk',
}); });
assert.deepEqual(model.keydown({ key: 'Escape' }), { assert.deepEqual(model.keydown({ key: 'Escape' }), {
handled: true, handled: true,
+2 -1
View File
@@ -730,7 +730,8 @@ html:not([lang='en']) .cockpit-roll-token.is-separator {
} }
.cockpit-vision-controls { .cockpit-vision-controls {
display: grid; display: grid;
grid-template-columns: 48px 58px 48px; /* The current style grows for a long translated name (ЗВИЧАЙНИЙ). */
grid-template-columns: 48px minmax(58px, max-content) 48px;
align-items: stretch; align-items: stretch;
gap: 2px; gap: 2px;
margin-top: 2px; margin-top: 2px;