feat(cockpit): enter Cockpit with only the ridden aircraft's layer on

Cockpit entry no longer needs Contacts or both flight feeds: a civilian
aircraft needs Live Flights, a military one Military Flights. Contacts and
every other layer can be turned on or off before or during a session, and
turning off the ridden layer ends it. A Context mode change in flight still
blocks entry.

When the other air feed is off, Live Signals and the Contact card say
FLIGHTS FEED OFF or MILITARY FEED OFF · PARTIAL PICTURE instead of
presenting a partial picture as complete. Voice refuses entry with
subject-layer-off rather than contacts-inactive, and its tool description
says Contacts is not required.

Double-clicking the followed aircraft enters Cockpit through the same gate.
The Cockpit takes the scene's double-click before Cesium's stock handler,
which would otherwise untrack a followed aircraft whose model is a
primitive. The entry also shows under the Intel HUD readout, sharing the
Contacts button's face. A test keeps the classes the Cockpit adds to the
body from matching another element's rule.
This commit is contained in:
Denis Groshev committed 2026-11-04 21:53:05 +01:00
1 parent 82f17e694e
commit 219f3e1079
37 files changed
+964 -134

No files matched your search

+19 -6
View File
@@ -1306,12 +1306,12 @@ test('voice Cockpit entry honours a requested contact layer before it enters', (
ui.indexOf("if (normalized === 'exit') {"),
);
assert.ok(branch, 'cockpit enter branch is missing');
const gateIndex = branch.indexOf('isEntryAllowed?.()');
const gateIndex = branch.indexOf('this._cockpitEntryRefusal()');
const retargetIndex = branch.indexOf('_retargetCockpitEntryLayer(');
const enterIndex = branch.indexOf('enterCockpitWithTracking({');
assert.ok(
gateIndex >= 0,
'entry consults the same gate the manual chip uses',
'entry refuses on the gate it can check before tracking',
);
assert.ok(
retargetIndex > gateIndex,
@@ -1362,9 +1362,21 @@ test('voice Cockpit entry refuses when the entry gate is shut', () => {
ui.indexOf("if (normalized === 'enter') {"),
ui.indexOf("if (normalized === 'exit') {"),
);
assert.match(branch, /if \(!this\.cockpitView\.isEntryAllowed\?\.\(\)\) \{/);
assert.match(branch, /Contacts must be active to enter Cockpit/);
assert.match(branch, /Contacts is still starting up/);
// Contacts is not required: only a Context change in flight or no aircraft
// layer to ride shuts the gate before tracking, and the entry itself then
// applies the manual chip's gate to the aircraft it rides.
assert.match(
branch,
/const refusal = this\._cockpitEntryRefusal\(\);\s*if \(refusal\) \{/,
);
assert.doesNotMatch(branch, /Contacts must be active/);
const refusal = ui.slice(
ui.indexOf(' _cockpitEntryRefusal() {'),
ui.indexOf(' getCockpitState() {'),
);
assert.match(refusal, /Context is still changing/);
assert.match(refusal, /No aircraft layer is on/);
assert.doesNotMatch(refusal, /[Cc]ontacts/);
});
test('cockpit state cannot report entryAllowed while already active', () => {
@@ -1382,7 +1394,8 @@ test('cockpit state cannot report entryAllowed while already active', () => {
);
assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained');
assert.match(state, /'contacts-starting'/);
assert.match(state, /'contacts-inactive'/);
assert.match(state, /'subject-layer-off'/);
assert.doesNotMatch(state, /'contacts-inactive'/, 'Contacts is not required');
assert.match(state, /'no-tracked-aircraft'/);
});
+34
View File
@@ -147,6 +147,40 @@ const snapshot = (distanceM = 2000) => ({
],
});
test('a feed-off row leads the rebuilt rows and the list stays five long', async (t) => {
const f = fixture(t);
const crowded = {
...snapshot(),
cohorts: [
{
id: 'flights',
count: 6,
nearest: [1, 2, 3, 4, 5, 6].map((n) => ({
id: `n${n}`,
label: `N${n}`,
distanceM: n * 1000,
})),
},
],
};
updateCockpitSignals.call(f.owner, crowded, 0, {
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
});
await nextTick();
assert.equal(f.owner.signalItems.length, 5);
assert.equal(f.owner.signalItems[0].key, 'feed-status');
assert.equal(f.owner.signalItems[0].tone, 'warning');
assert.equal(f.owner.signalItems[0].target, null);
assert.match(f.list.textContent, /MILITARY FEED OFF · PARTIAL PICTURE/);
updateCockpitSignals.call(f.owner, crowded, 0);
assert.equal(
f.owner.signalItems.some((item) => item.key === 'feed-status'),
false,
'with the feed back, the row goes',
);
});
test('rows render the signals the controller presents', async (t) => {
const f = fixture(t);
await render(f, [
+23 -16
View File
@@ -411,30 +411,37 @@ export function contextRestoreLayerIds(snapshot = {}) {
return restored;
}
/** The layers an aircraft Cockpit subject can come from. */
export const COCKPIT_AIRCRAFT_LAYER_IDS = Object.freeze([
'flights',
'military',
]);
/**
* Cockpit entry belongs to the operational Contacts context bundle. A tracked
* aircraft alone is not sufficient: both observed-flight feeds must still be
* active so the cockpit's surrounding-contact picture is not presented as a
* complete context view when one source is disabled.
* Cockpit entry needs only the aircraft it rides: that aircraft's own layer
* (Live Flights for a civilian, Military Flights for a military aircraft) must
* be on. Contacts and the other feeds are not required. When a source the
* surrounding picture uses is off, the Cockpit's Contact card and Live
* Signals say the picture is partial instead of blocking entry, so a partial
* picture is never presented as complete. A Context mode change in flight
* still owns the camera, so entry waits it out.
*
* @param {object} input Current context and dependency visibility.
* @param {string|null} input.contextMode Active Context mode.
* @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled.
* @param {boolean} input.flightsEnabled Whether Live Flights is enabled.
* @param {boolean} input.militaryEnabled Whether Military Flights is enabled.
* @param {object} input Current subject and Context state.
* @param {string|null} input.subjectLayerId Layer of the tracked aircraft
* ('flights' or 'military'), or null with none tracked.
* @param {boolean} input.subjectLayerEnabled Whether that layer is enabled.
* @param {boolean} input.contextModeChanging Whether a Context transaction is unsettled.
* @returns {boolean} Whether cockpit entry may be offered or activated.
*/
export function cockpitEntryAllowed({
contextMode,
subjectLayerId,
subjectLayerEnabled,
contextModeChanging,
flightsEnabled,
militaryEnabled,
}) {
return (
contextMode === 'flights' &&
!contextModeChanging &&
Boolean(flightsEnabled) &&
Boolean(militaryEnabled)
COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) &&
Boolean(subjectLayerEnabled) &&
!contextModeChanging
);
}
+18 -23
View File
@@ -400,37 +400,32 @@ test('explicit layer intent finishing during restore overrides the stale queued
}
});
test('cockpit entry requires Contacts context with both aircraft feeds enabled', () => {
test("cockpit entry needs only the tracked aircraft's own layer", () => {
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: 'flights',
subjectLayerEnabled: true,
contextModeChanging: false,
flightsEnabled: true,
militaryEnabled: true,
}), true);
}), true, 'a civilian needs Live Flights alone: no Contacts, no Military');
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: 'military',
subjectLayerEnabled: true,
contextModeChanging: false,
}), true, 'a military aircraft needs Military Flights alone');
assert.equal(cockpitEntryAllowed({
subjectLayerId: 'flights',
subjectLayerEnabled: true,
contextModeChanging: true,
flightsEnabled: true,
militaryEnabled: true,
}), false, 'Cockpit remains unavailable until Contacts activation settles');
}), false, 'Cockpit remains unavailable until a Context mode change settles');
assert.equal(cockpitEntryAllowed({
contextMode: null,
subjectLayerId: 'military',
subjectLayerEnabled: false,
contextModeChanging: false,
flightsEnabled: true,
militaryEnabled: true,
}), false, 'ordinary layer selection is not an operational Contacts context');
}), false, "the ridden aircraft's own layer is required");
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
subjectLayerId: null,
subjectLayerEnabled: false,
contextModeChanging: false,
flightsEnabled: true,
militaryEnabled: false,
}), false, 'the military contact feed is required');
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
contextModeChanging: false,
flightsEnabled: false,
militaryEnabled: true,
}), false, 'the civilian contact feed is required');
}), false, 'there is no aircraft to ride');
});
test('context teardown guard restores coordination state after success and failure', async () => {
+3 -2
View File
@@ -1039,10 +1039,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// The Utilities panel adds one set_panel_open target; Sea Marks and Ski
// Pistes extend the generated layer enums and alias hint; voice geometry
// adds the analyst area scopes and the osm-places layer.
assert.equal(block.length, 31318, 'serialized tool schema length drifted');
// control_cockpit states the own-layer entry rule (Contacts not required).
assert.equal(block.length, 31372, 'serialized tool schema length drifted');
assert.equal(
crypto.createHash('sha256').update(block).digest('hex'),
'f5b42f41c2f575838f979e88a1dbff615a77ccd85fdf8fcd0a0a47d02f7d3ddc',
'7f593e78c232db972b04441f830b6a60f93916a6e5bc5a68eca7422cd1035508',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
);
const instructions = fs.readFileSync(
+3
View File
@@ -2187,6 +2187,9 @@
"{value} KM/H": "{value} كم/س",
"{label} · {radius} AIR/SEA WINDOW": "{label} · نطاق جو/بحر {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · المنشآت {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "مصدر البيانات العسكرية متوقف · صورة جزئية",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "مصدر الرحلات متوقف · صورة جزئية",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "قائمة الطائرات القريبة غير مكتملة · لا يعني أن الوضع آمن",
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
"ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}",
"ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}",
+3
View File
@@ -2087,6 +2087,9 @@
"{value} KM/H": "{value} KM/H",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} LUFT-/SEEFENSTER",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ANLAGEN {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "MILITÄR-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUG-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "FLUGZEUGE IN DER NÄHE UNVOLLSTÄNDIG · KEINE ENTWARNUNG",
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
"ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}",
"ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}",
+3
View File
@@ -1920,6 +1920,9 @@
"N/A": "N/D",
"{label} · {radius} AIR/SEA WINDOW": "{label} · VENTANA AIRE/MAR {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA · PANORAMA PARCIAL",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA · PANORAMA PARCIAL",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS · NO IMPLICA TODO DESPEJADO",
"{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE",
"ft": "ft",
"kts": "kn",
+3
View File
@@ -1660,6 +1660,9 @@
"REGIONAL NEWS": "ACTUS RÉGIONALES",
"{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE COUPÉ · SITUATION PARTIELLE",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS COUPÉ · SITUATION PARTIELLE",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES INCOMPLETS · PAS UN FEU VERT",
"CONTEXT STANDBY": "CONTEXTE EN VEILLE",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIER RELEVÉ CONNU · PAS UN FEU VERT",
+3
View File
@@ -1660,6 +1660,9 @@
"REGIONAL NEWS": "NOTIZIE REGIONALI",
"{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITARE DISATTIVATO · QUADRO PARZIALE",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED VOLI DISATTIVATO · QUADRO PARZIALE",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA",
"CONTEXT STANDBY": "CONTESTO IN ATTESA",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA",
+4 -1
View File
@@ -1612,6 +1612,9 @@
"REGIONAL NEWS": "地域ニュース",
"{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィードオフ · 部分的な状況",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィードオフ · 部分的な状況",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "付近の航空機情報は不完全 · 安全の保証ではありません",
"CONTEXT STANDBY": "コンテキスト待機",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "コンタクト喪失 · 最終既知の値 · 安全の保証ではありません",
@@ -1684,7 +1687,7 @@
"Restore real aircraft": "実際の航空機に戻す",
"Reclassify as TR-3B": "TR-3Bに再分類",
"TRACK ACQUIRED": "追跡開始",
"{label} · COURSE {heading}°": "{label} · 針路 {heading}°",
"{label} · COURSE {heading}°": "{label} · 対地針路 {heading}°",
"Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください",
"No selected data layers": "選択中のデータレイヤーはありません",
"{count} data layers could not be cleared": {
+4 -1
View File
@@ -1660,6 +1660,9 @@
"REGIONAL NEWS": "NOTÍCIAS DA ÁREA",
"{label} · {radius} AIR/SEA WINDOW": "{label} · AR/MAR {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITAR DESLIGADO · QUADRO PARCIAL",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED DE VOOS DESLIGADO · QUADRO PARCIAL",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES PRÓXIMAS INCOMPLETAS · NÃO GARANTE SEGURANÇA",
"CONTEXT STANDBY": "CONTEXTO EM ESPERA",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATO PERDIDO · ÚLTIMA LEITURA CONHECIDA · NÃO GARANTE SEGURANÇA",
@@ -1736,7 +1739,7 @@
"Restore real aircraft": "Restaurar a aeronave real",
"Reclassify as TR-3B": "Reclassificar como TR-3B",
"TRACK ACQUIRED": "RASTRO ADQUIRIDO",
"{label} · COURSE {heading}°": "{label} · CURSO {heading}°",
"{label} · COURSE {heading}°": "{label} · RUMO {heading}°",
"Context could not restore every layer; try again": "O contexto não conseguiu restaurar todas as camadas; tente novamente",
"No selected data layers": "Nenhuma camada de dados selecionada",
"{count} data layers could not be cleared": {
+4 -1
View File
@@ -458,7 +458,7 @@
"SOURCE STATUS UNAVAILABLE": "СТАТУС ИСТОЧНИКА НЕДОСТУПЕН",
"Select flight {title}": "Выбрать рейс {title}",
"TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА",
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°",
"{label} · COURSE {heading}°": "{label} · ПУТЕВОЙ УГОЛ {heading}°",
"Restore real aircraft": "Вернуть реальный самолёт",
"Reclassify as TR-3B": "Переклассифицировать как TR-3B",
"No selected data layers": "Нет выбранных слоёв данных",
@@ -1953,6 +1953,9 @@
"N/A": "Н/Д",
"{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБЪЕКТЫ: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "ВОЕННЫЙ ПОТОК ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТОК РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖНИЕ ВОЗДУШНЫЕ СУДА: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО",
"{source} · LOCATION QUERY · RECENT": "{source} · ЗАПРОС ПО МЕСТУ · НЕДАВНИЕ",
"ft": "фт",
"kts": "уз",
+4 -1
View File
@@ -1683,6 +1683,9 @@
"REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ",
"{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "ВІЙСЬКОВИЙ ПОТІК ВИМК. · КАРТИНА НЕПОВНА",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТІК РЕЙСІВ ВИМК. · КАРТИНА НЕПОВНА",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЗЬКІ ПОВІТРЯНІ СУДНА: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
"CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ВТРАЧЕНО · ОСТАННІ ВІДОМІ ПОКАЗНИКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
@@ -1761,7 +1764,7 @@
"Restore real aircraft": "Повернути справжній літак",
"Reclassify as TR-3B": "Перекласифікувати як TR-3B",
"TRACK ACQUIRED": "ТРЕК ЗАХОПЛЕНО",
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°",
"{label} · COURSE {heading}°": "{label} · ШЛЯХОВИЙ КУТ {heading}°",
"Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз",
"No selected data layers": "Шари даних не вибрано",
"{count} data layers could not be cleared": {
+4 -1
View File
@@ -1588,6 +1588,9 @@
"REGIONAL NEWS": "地区新闻",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "军用数据源已关闭 · 态势不完整",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班数据源已关闭 · 态势不完整",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器信息不完整 · 不代表安全无虞",
"CONTEXT STANDBY": "态势待机",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞",
@@ -1660,7 +1663,7 @@
"Restore real aircraft": "恢复真实飞机",
"Reclassify as TR-3B": "重新归类为 TR-3B",
"TRACK ACQUIRED": "已获取航迹",
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°",
"{label} · COURSE {heading}°": "{label} · 对地航向 {heading}°",
"Context could not restore every layer; try again": "态势未能恢复所有图层,请重试",
"No selected data layers": "没有已选数据图层",
"{count} data layers could not be cleared": {
+4 -1
View File
@@ -1578,6 +1578,9 @@
"REGIONAL NEWS": "地區新聞",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用數據源已關閉 · 態勢不完整",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班數據源已關閉 · 態勢不完整",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器資訊不完整 · 不代表安全無虞",
"CONTEXT STANDBY": "態勢待機",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞",
@@ -1650,7 +1653,7 @@
"Restore real aircraft": "恢復真實飛機",
"Reclassify as TR-3B": "重新歸類為 TR-3B",
"TRACK ACQUIRED": "已獲取航跡",
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°",
"{label} · COURSE {heading}°": "{label} · 對地航向 {heading}°",
"Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試",
"No selected data layers": "沒有已選數據圖層",
"{count} data layers could not be cleared": {
+21 -1
View File
@@ -11,6 +11,9 @@ const INDEX_HTML = path.join(REPO_ROOT, 'index.html');
/** The glyph written as element text: `<span class="material-symbols-outlined">radar</span>`. */
const SPAN_TEXT =
/class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*([a-z0-9_]+)\s*</g;
/** A glyph chosen in a Vue template: `<span class="material-symbols-outlined">{{ a ? 'x' : 'y' }}</span>`. */
const SPAN_MUSTACHE =
/class="[^"]*material-symbols-outlined[^"]*"[^>]*>\s*\{\{([^}]{0,400})\}\}\s*</g;
/** A whole `textContent =` statement, across lines, so a multi-line ternary is read once. */
const TEXT_ASSIGNMENT = /(?:textContent|innerText)\s*=\s*([^;]{0,400})/gs;
const STRING_LITERAL = /['"`]([a-z0-9_]{2,})['"`]/g;
@@ -64,7 +67,24 @@ function referencedGlyphs() {
};
for (const match of source.matchAll(SPAN_TEXT)) add(match[1]);
for (const match of source.matchAll(ICON_PROPERTY)) add(match[1]);
for (const match of source.matchAll(TEXT_ASSIGNMENT)) {
// A template glyph bound to a computed (`{{ entryIcon }}`) is whatever
// that computed returns.
for (const [, expression] of source.matchAll(SPAN_MUSTACHE)) {
const name = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(expression)?.[1];
if (!name) continue;
const body = new RegExp(
`\\b${name}\\s*=\\s*computed\\(\\(\\)\\s*=>\\s*\\{([\\s\\S]{0,600}?)\\n\\}\\);`,
).exec(source)?.[1];
for (const literal of body?.matchAll(
/\breturn\s+['"`]([a-z0-9_]{2,})['"`]/g,
) ?? [])
add(literal[1]);
}
const expressions = [
...source.matchAll(TEXT_ASSIGNMENT),
...source.matchAll(SPAN_MUSTACHE),
];
for (const match of expressions) {
const statement = match[1].replaceAll('?.', '.');
const assigned = statement.includes('?')
? statement.slice(statement.indexOf('?') + 1)
+91
View File
@@ -0,0 +1,91 @@
// src/ui/cockpitBodyClasses.test.mjs — the classes the Cockpit puts on
// <body> must not also name an element's rule. A bare `.name { … }` written
// for a panel matches <body> the moment <body> carries `name`, and its
// pointer-events, position and overflow then apply to the whole page.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const UI_ROOT = fileURLToPath(new URL('.', import.meta.url));
const SRC_ROOT = path.resolve(UI_ROOT, '..');
const CONTROLLER = path.join(UI_ROOT, 'cockpitTrackingController.js');
/** Every stylesheet the app ships. */
function stylesheets(directory = SRC_ROOT) {
const files = [];
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) files.push(...stylesheets(absolute));
else if (entry.isFile() && entry.name.endsWith('.css'))
files.push(absolute);
}
return files;
}
/** A selector list split on its top-level commas, not those in `:not(a, b)`. */
function splitSelectorList(list) {
const parts = [];
let depth = 0;
let start = 0;
for (let i = 0; i < list.length; i += 1) {
if (list[i] === '(') depth += 1;
else if (list[i] === ')') depth -= 1;
else if (list[i] === ',' && depth === 0) {
parts.push(list.slice(start, i));
start = i + 1;
}
}
parts.push(list.slice(start));
return parts;
}
/** The classes the tracking controller adds to <body>. */
function cockpitBodyClasses() {
const source = readFileSync(CONTROLLER, 'utf8');
const names = [
...source.matchAll(/body\.classList\.add\(\s*'([a-z0-9-]+)'/g),
].map((match) => match[1]);
return [...new Set(names)];
}
test('the Cockpit adds its subject classes to <body>', () => {
const classes = cockpitBodyClasses();
for (const name of ['cockpit-mode']) {
assert.ok(classes.includes(name), `${name} is not added to <body>`);
}
});
test('no stylesheet rule matches a Cockpit body class on another element', () => {
const offenders = [];
for (const name of cockpitBodyClasses()) {
// `.name` as a whole class token, not as `.name-suffix`.
const token = new RegExp(`\\.${name}(?![\\w-])`);
for (const file of stylesheets()) {
const css = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '');
for (const [, selectors] of css.matchAll(/([^{}]+)\{/g)) {
for (const selector of splitSelectorList(selectors)) {
// Prettier wraps long `:not(\n .name\n)` arguments; unwrap them.
const compound = selector
.trim()
.replace(/\(\s+/g, '(')
.replace(/\s+\)/g, ')');
if (!token.test(compound)) continue;
// Allowed only on <body> itself: `body.name` or `body:not(.name)`.
const onBody = new RegExp(
`(^|[\\s>+~])body[^\\s>+~]*\\.${name}(?![\\w-])`,
);
if (onBody.test(compound)) continue;
offenders.push(`${path.relative(SRC_ROOT, file)}: ${compound}`);
}
}
}
}
assert.deepEqual(
offenders,
[],
'Selectors that match a Cockpit body class without `body.`: ' +
offenders.join('; '),
);
});
+42 -9
View File
@@ -24,6 +24,27 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
});
/**
* Which air feed the aircraft Cockpit's surrounding picture is missing. Entry
* needs only the ridden aircraft's own layer, so the other one may be off;
* the panels then say the picture is partial rather than present it as
* complete. Null with both feeds on, or when the owner cannot tell.
* @returns {?{title: string, detail: string}}
*/
export function readPartialPicture() {
const isLayerEnabled = this.services.isLayerEnabled;
if (typeof isLayerEnabled !== 'function') return null;
const militaryOff = !isLayerEnabled('military');
const flightsOff = !isLayerEnabled('flights');
if (!militaryOff && !flightsOff) return null;
return {
title: militaryOff
? t('MILITARY FEED OFF · PARTIAL PICTURE')
: t('FLIGHTS FEED OFF · PARTIAL PICTURE'),
detail: t('NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR'),
};
}
/** Hand a fresh copy of the Contact card to the view. */
export function publishContextView() {
const view = this.contextView;
@@ -52,6 +73,7 @@ export function updateContext(info, heading) {
this.services.militaryAwarenessLayer.getContextSnapshot?.() || null;
const trackedId = info.icao24 || info.id;
const readout = resolveCockpitContextReadout({ snapshot, info });
const partial = readPartialPicture.call(this);
if (!readout.visible) {
this.showContextCard(false);
this.contextLayoutStamp = null;
@@ -61,6 +83,14 @@ export function updateContext(info, heading) {
t('CONTEXT STANDBY'),
t('ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS'),
);
if (partial)
this.pushCockpitSignal(
'feed-status',
'warning',
partial.title,
partial.detail,
);
else this.dropCockpitSignal?.('feed-status');
return;
}
@@ -171,19 +201,22 @@ export function updateContext(info, heading) {
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
});
}
view.uncertainty = unknownCount
? tn(
unknownCount,
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
)
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
// A feed that is off outranks unknown inputs: the picture is partial.
view.uncertainty = partial
? partial.title
: unknownCount
? tn(
unknownCount,
'{count} INPUT UNKNOWN · NOT AN ALL-CLEAR',
'{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR',
)
: t('AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR');
view.updated = Number.isFinite(snapshot.evaluatedAt)
? new Date(snapshot.evaluatedAt).toISOString().slice(11, 19) + 'Z'
: '--:--:--Z';
view.state = unknownCount ? 'uncertain' : 'current';
view.state = unknownCount || partial ? 'uncertain' : 'current';
this.publishContextView();
this.updateCockpitSignals(snapshot, unknownCount);
this.updateCockpitSignals(snapshot, unknownCount, partial);
if (this.contextLayoutStamp !== snapshot.evaluatedAt) {
this.contextLayoutStamp = snapshot.evaluatedAt;
this.scheduleContextLayout();
+57
View File
@@ -204,6 +204,63 @@ test('without a snapshot the card stands by, hidden', async (t) => {
assert.equal(signals.at(-1)[2], 'CONTEXT STANDBY');
});
test('a feed that is off makes the card and Live Signals say the picture is partial', async (t) => {
// Every input known, so only the feed that is off can make it partial.
const { $, text, owner } = fixture(
t,
snapshot({
cohorts: [
{
id: 'flights',
label: 'Flights',
count: 12,
relationship: 'OBSERVED',
nearest: [{ id: 'N1', distanceM: 4200 }],
},
],
}),
);
const rebuilt = [];
owner.updateCockpitSignals = (...args) => rebuilt.push(args);
owner.services.isLayerEnabled = () => true;
updateContext.call(owner, INFO, 0);
await nextTick();
assert.equal($('#cockpit-context').dataset.state, 'current');
assert.equal(rebuilt.at(-1)[2], null, 'both feeds on: nothing to add');
owner.services.isLayerEnabled = (layerId) => layerId !== 'military';
updateContext.call(owner, INFO, 0);
await nextTick();
assert.equal($('#cockpit-context').dataset.state, 'uncertain');
assert.equal(
text('#cockpit-context-uncertainty'),
'MILITARY FEED OFF · PARTIAL PICTURE',
);
assert.deepEqual(rebuilt.at(-1)[2], {
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
});
});
test('without Contacts, a feed that is off adds a signal until it is back', async (t) => {
const { owner, signals } = fixture(t, null);
const dropped = [];
owner.dropCockpitSignal = (key) => dropped.push(key);
// Riding a military aircraft with Live Flights off.
owner.services.isLayerEnabled = (layerId) => layerId === 'military';
updateContext.call(owner, { ...INFO, layerId: 'military' }, 0);
assert.equal(signals.at(-2)[2], 'CONTEXT STANDBY');
assert.deepEqual(signals.at(-1), [
'feed-status',
'warning',
'FLIGHTS FEED OFF · PARTIAL PICTURE',
'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
]);
owner.services.isLayerEnabled = () => true;
updateContext.call(owner, { ...INFO, layerId: 'military' }, 0);
assert.deepEqual(dropped, ['feed-status']);
});
test('the weather toggle renders its state and the action it offers', async (t) => {
const { $, text, owner } = fixture(t);
syncWeatherToggle.call(owner, true);
+34 -3
View File
@@ -1,6 +1,8 @@
/** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */
import {
readAircraftInfo,
readSubjectLayerId,
resolveEntryKind,
dispatchCockpitModeChanged,
toggleTrackedTr3b,
syncTr3bToggle,
@@ -48,6 +50,7 @@ import {
relocalizeBrief,
} from './cockpitBriefing.js';
import {
dropCockpitSignal,
handleSignalClick,
publishCockpitSignals,
pushCockpitSignal,
@@ -60,7 +63,11 @@ import {
syncContextLayout,
syncSignalLayout,
} from './cockpitLayout.js';
import { onKeyDown } from './cockpitInput.js';
import {
onKeyDown,
onSceneDoubleClick,
routeSceneDoubleClick,
} from './cockpitInput.js';
import * as Cesium from 'cesium';
export class CockpitViewController {
@@ -205,6 +212,13 @@ export class CockpitViewController {
}),
);
this._listen(this.entry, 'click', () => this.enter());
// Double-clicking the followed track is a third way in.
this._listenerRemovers.push(routeSceneDoubleClick.call(this));
// The readout's entry renders inside the Intel HUD, which may mount after
// this controller, so its clicks are delegated.
this._listen(document, 'click', (event) => {
if (event.target?.closest?.('#cockpit-entry-readout')) this.enter();
});
this._listen(this.tr3bToggle, 'click', () => this.toggleTrackedTr3b());
this._listen(this.mapViewButton, 'click', () => this.exit());
this._listen(this.visionPrevious, 'click', () => this.cycleVisionMode(-1));
@@ -272,6 +286,12 @@ export class CockpitViewController {
readAircraftInfo() {
return readAircraftInfo.call(this);
}
readSubjectLayerId() {
return readSubjectLayerId.call(this);
}
resolveEntryKind() {
return resolveEntryKind.call(this);
}
dispatchCockpitModeChanged(active, info) {
return dispatchCockpitModeChanged.call(this, active, info);
}
@@ -371,8 +391,16 @@ export class CockpitViewController {
pushCockpitSignal(key, tone, title, detail, target) {
return pushCockpitSignal.call(this, key, tone, title, detail, target);
}
updateCockpitSignals(snapshot, unknownCount) {
return updateCockpitSignals.call(this, snapshot, unknownCount);
dropCockpitSignal(key) {
return dropCockpitSignal.call(this, key);
}
updateCockpitSignals(snapshot, unknownCount, partialPicture) {
return updateCockpitSignals.call(
this,
snapshot,
unknownCount,
partialPicture,
);
}
scheduleContextLayout() {
return scheduleContextLayout.call(this);
@@ -392,6 +420,9 @@ export class CockpitViewController {
onKeyDown(event) {
return onKeyDown.call(this, event);
}
onSceneDoubleClick(position) {
return onSceneDoubleClick.call(this, position);
}
handleSignalClick(event) {
return handleSignalClick.call(this, event);
+57 -21
View File
@@ -1,6 +1,9 @@
import { nextTick } from 'vue';
import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js';
import { cockpitEntryAllowed } from '../contextModePolicy.js';
import {
COCKPIT_AIRCRAFT_LAYER_IDS,
cockpitEntryAllowed,
} from '../contextModePolicy.js';
import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js';
import { enterCockpitWithTracking } from '../cockpitTracking.js';
import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js';
@@ -72,19 +75,24 @@ export class CockpitCoordinator {
fetchRegionalBrief,
regionalDistanceM,
weatherCodeLabel,
// The Contact card and Live Signals say which feeds are off.
isLayerEnabled: (layerId) => this._isLayerEnabled(layerId),
},
onVisionChange: (mode, active, options) =>
this._setCockpitVision(mode, active, options),
getInitialVisionMode: () => cockpitVisionModeForStyle(this.activeStyle),
onCameraTakeover: () =>
this._stampNavigation({ cancelPendingSelection: false }),
isEntryAllowed: () =>
cockpitEntryAllowed({
contextMode: this._contextMode,
// Only the tracked aircraft's own layer gates entry (see the policy).
isEntryAllowed: () => {
const subjectLayerId =
this.cockpitView?.readAircraftInfo?.()?.layerId ?? null;
return cockpitEntryAllowed({
subjectLayerId,
subjectLayerEnabled: this._isLayerEnabled(subjectLayerId),
contextModeChanging: this._contextModeChanging,
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
militaryEnabled: !!this._dataManager?.isEnabled('military'),
}),
});
},
publish: this.publishCockpit,
isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(),
onEntered: () => this.enterPanels(),
@@ -115,6 +123,28 @@ export class CockpitCoordinator {
get activeStyle() {
return this.readActiveStyle();
}
/** Whether a data layer is enabled; false for no layer. */
_isLayerEnabled(layerId) {
return Boolean(layerId) && !!this._dataManager?.isEnabled(layerId);
}
/** Whether either aircraft layer is on, so there is an aircraft to ride. */
get _aircraftLayerEnabled() {
return COCKPIT_AIRCRAFT_LAYER_IDS.some((layerId) =>
this._isLayerEnabled(layerId),
);
}
/**
* Why voice entry must refuse before it touches tracking, or null. The
* tracked aircraft's own layer is checked again by the entry itself.
* @returns {string|null}
*/
_cockpitEntryRefusal() {
if (this._contextModeChanging)
return 'Context is still changing — try Cockpit again in a moment';
if (!this._aircraftLayerEnabled)
return 'No aircraft layer is on — turn on Flights or Military Flights and track an aircraft to enter Cockpit';
return null;
}
getCockpitState() {
const { militaryAwarenessLayer } = this.services;
const snapshot = militaryAwarenessLayer.getContextSnapshot?.();
@@ -129,19 +159,24 @@ export class CockpitCoordinator {
const entryAllowed =
!active &&
Boolean(gateOpen && info && this.viewer?.trackedEntity?.position);
// Entry needs only the ridden aircraft's own layer: with one tracked,
// that layer; with none, any aircraft layer to track one from.
const subjectLayerOff = info
? !this._isLayerEnabled(info.layerId)
: !this._aircraftLayerEnabled;
return {
active,
entryAllowed,
// Why entry is unavailable, so a refusal can be explained rather than
// guessed at.
// guessed at. Contacts is not required, so it is never the reason.
entryBlockedReason:
entryAllowed || active
? null
: !gateOpen
? this._contextModeChanging
? 'contacts-starting'
: 'contacts-inactive'
: 'no-tracked-aircraft',
: this._contextModeChanging
? 'contacts-starting'
: subjectLayerOff
? 'subject-layer-off'
: 'no-tracked-aircraft',
visionMode: this.cockpitView?.visionMode || null,
subject: info
? {
@@ -260,17 +295,18 @@ export class CockpitCoordinator {
};
}
if (normalized === 'enter') {
// Entry is gated exactly as the manual entry chip is. Attempting it while
// the gate is shut produced the half-entered look the operator reported
// (a plane anchored under the camera with no cockpit around it), so
// refuse with the reason instead of trying.
if (!this.cockpitView.isEntryAllowed?.()) {
// Attempting entry while the gate is shut produced the half-entered look
// the operator reported (a plane anchored under the camera with no
// cockpit around it), so refuse with the reason instead of trying. The
// aircraft may not be tracked yet (a selection or a requested layer is
// adopted below), so this checks what can be known now; the entry
// itself then applies the manual chip's gate to the aircraft it rides.
const refusal = this._cockpitEntryRefusal();
if (refusal) {
return {
ok: false,
action: 'control_cockpit',
error: this._contextModeChanging
? 'Contacts is still starting up — try Cockpit again in a moment'
: 'Contacts must be active to enter Cockpit — say "open contacts" first',
error: refusal,
state: this.getCockpitState(),
};
}
+234
View File
@@ -0,0 +1,234 @@
// src/ui/cockpitEntryGate.test.mjs — first person needs only the layer of
// the vehicle it rides: Contacts and the other feeds may be off before or
// during a session, turning the ridden layer off ends it, and the voice gate
// and state say so without claiming Contacts is required. Over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createApp, defineComponent, h, nextTick } from 'vue';
import { CockpitCoordinator } from './cockpitCoordinator.js';
import { _handleContextLayerChange } from './contextLayerChanges.js';
import { useCockpitStore } from './stores/cockpit.js';
import { useHudStore } from './stores/hud.js';
import ContextView from './views/ContextView.vue';
import IntelHudView from './views/IntelHudView.vue';
const CIVIL = Object.freeze({
icao24: 'abc123',
callsign: 'UAL123',
track: 92,
latitude: 30,
longitude: -97,
});
const MILITARY = Object.freeze({
...CIVIL,
icao24: 'ae0001',
callsign: 'RCH1',
});
function fixture(
t,
{
enabled = [],
tracked = null,
contextMode = null,
changing = false,
} = {},
) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(
defineComponent(() => () => [h(ContextView), h(IntelHudView)]),
).use(pinia);
app.mount(host);
const store = useCockpitStore(pinia);
const layers = new Set(enabled);
const dataManager = { isEnabled: (layerId) => layers.has(layerId) };
// A layer that is off has no tracked aircraft, as the flight layers clear
// tracking on disable.
const trackedFrom = (layerId) => () =>
tracked?.layerId === layerId && layers.has(layerId) ? tracked.info : null;
const entity = {
position: { getValue: () => null },
show: true,
gevTrackedId: tracked ? `${tracked.layerId}:${tracked.info.icao24}` : '',
};
const listeners = () => ({ addEventListener: () => () => {} });
const viewer = {
trackedEntity: tracked ? entity : undefined,
clock: { currentTime: null },
camera: { cancelFlight() {}, frustum: { fov: 1, aspectRatio: 1 } },
entities: { contains: (candidate) => candidate === entity },
scene: {
preUpdate: listeners(),
screenSpaceCameraController: { enableInputs: true },
},
trackedEntityChanged: listeners(),
};
const context = { mode: contextMode, changing };
const coordinator = new CockpitCoordinator({
viewer,
services: {
flightsLayer: { getTrackedInfo: trackedFrom('flights') },
militaryFlightsLayer: { getTrackedInfo: trackedFrom('military') },
militaryAwarenessLayer: { getContextSnapshot: () => null },
isTr3b: () => false,
holdContinuousRender() {},
releaseContinuousRender() {},
},
elements: {},
operations: {
publishCockpit: { presence: (view) => store.publishPresence(view) },
_stampNavigation() {},
getAircraftTrackingTarget: () =>
tracked ? { layerId: tracked.layerId, id: tracked.info.icao24 } : null,
},
readDataManager: () => dataManager,
readContext: () => context,
readActiveStyle: () => 'normal',
readUtilityDisclosureOpen: () => false,
enterPanels() {},
exitPanels() {},
});
const cockpit = coordinator.cockpitView;
// The HUD, briefing and signals have their own tests.
Object.assign(cockpit, {
updateHud() {},
setVisionMode() {},
showBriefPage() {},
startBriefRotation() {},
stopBriefRotation() {},
pushCockpitSignal() {},
scheduleContextLayout() {},
clearPredictiveRoute() {},
});
t.after(() => {
coordinator.stop();
app.unmount();
document.body.classList.remove('cockpit-mode');
});
/** Turn a layer off and tell the Context owner, as the data manager does. */
const disable = (layerId) => {
layers.delete(layerId);
_handleContextLayerChange.call(
{
cockpitView: cockpit,
_dataManager: dataManager,
_contextMode: null,
_contextModeChanging: false,
_contextSessionSnapshot: null,
_contextRestoreState: null,
_syncContextModeButtons() {},
},
{ type: 'visibility', layerId, enabled: false, origin: 'user' },
);
};
const $ = (selector) => document.querySelector(selector);
return { $, coordinator, cockpit, store, layers, disable, pinia };
}
test('a civilian aircraft enters with Live Flights alone: no Contacts, no Military', (t) => {
const { coordinator, cockpit } = fixture(t, {
enabled: ['flights'],
tracked: { layerId: 'flights', info: CIVIL },
});
const state = coordinator.getCockpitState();
assert.equal(state.entryAllowed, true);
assert.equal(state.entryBlockedReason, null);
assert.equal(cockpit.enter(), true);
});
test('a military aircraft enters with Military Flights alone', (t) => {
const { coordinator, cockpit } = fixture(t, {
enabled: ['military'],
tracked: { layerId: 'military', info: MILITARY },
});
assert.equal(coordinator.getCockpitState().entryAllowed, true);
assert.equal(cockpit.enter(), true);
});
test('a Context change in flight still blocks entry, and says so', (t) => {
const { coordinator, cockpit } = fixture(t, {
enabled: ['flights', 'military'],
tracked: { layerId: 'flights', info: CIVIL },
contextMode: 'flights',
changing: true,
});
const state = coordinator.getCockpitState();
assert.equal(state.entryAllowed, false);
assert.equal(state.entryBlockedReason, 'contacts-starting');
assert.equal(cockpit.enter(), false);
const voice = coordinator.controlCockpit('enter');
assert.equal(voice.ok, false);
assert.match(voice.error, /Context is still changing/);
assert.equal(cockpit.active, false);
});
test('with no aircraft layer on, entry is refused for that reason, never for Contacts', (t) => {
const { coordinator, cockpit } = fixture(t);
const state = coordinator.getCockpitState();
assert.equal(state.entryBlockedReason, 'subject-layer-off');
const voice = coordinator.controlCockpit('enter');
assert.equal(voice.ok, false);
assert.match(voice.error, /No aircraft layer is on/);
assert.doesNotMatch(voice.error, /contacts/i);
assert.equal(voice.state.entryBlockedReason, 'subject-layer-off');
assert.equal(cockpit.active, false);
});
test('with an aircraft layer on but nothing tracked, the reason is the missing aircraft', (t) => {
const { coordinator } = fixture(t, { enabled: ['military'] });
assert.equal(
coordinator.getCockpitState().entryBlockedReason,
'no-tracked-aircraft',
);
});
test('voice enters without Contacts when the ridden layer is on', (t) => {
const { coordinator, cockpit } = fixture(t, {
enabled: ['flights'],
tracked: { layerId: 'flights', info: CIVIL },
});
const result = coordinator.controlCockpit('enter');
assert.equal(result.ok, true, result.error);
assert.equal(cockpit.active, true);
});
test('other layers may be turned off during a session; the ridden layer ends it', (t) => {
const { cockpit, disable } = fixture(t, {
enabled: ['flights', 'military', 'ais-live-vessels'],
tracked: { layerId: 'flights', info: CIVIL },
});
assert.equal(cockpit.enter(), true);
disable('military');
disable('ais-live-vessels');
assert.equal(cockpit.active, true, 'other feeds are free to go off');
disable('flights');
assert.equal(cockpit.active, false, 'the ridden layer going off ends it');
});
test('the readout offers the same entry, labelled by kind, and enters on click', async (t) => {
const { $, cockpit, pinia } = fixture(t, {
enabled: ['flights'],
tracked: { layerId: 'flights', info: CIVIL },
});
useHudStore(pinia);
const readoutEntry = $('#cockpit-entry-readout');
assert.equal(readoutEntry.hidden, true, 'hidden until entry is available');
cockpit.syncEntry();
await nextTick();
assert.equal(readoutEntry.hidden, false);
assert.match(readoutEntry.textContent, /flight/);
assert.match(readoutEntry.textContent, /COCKPIT/);
assert.equal(
$('#cockpit-entry').hidden,
false,
'the Contacts entry follows the same presence',
);
readoutEntry.click();
await nextTick();
assert.equal(cockpit.active, true);
assert.equal(readoutEntry.hidden, true, 'inside, there is nothing to enter');
});
+94
View File
@@ -1,5 +1,99 @@
/** Route Cockpit keys while preserving the active disclosure and form-control owner. */
import * as Cesium from 'cesium';
import { isPointerFree } from '../data/inputOwnership.js';
/** How far from the followed track a double-click may land, CSS pixels. */
export const SUBJECT_DOUBLE_CLICK_RADIUS_PX = 32;
/**
* Whether a double-click landed on the followed track's mark.
* @param {{x: number, y: number}|null} click Canvas position of the click.
* @param {{x: number, y: number}|null|undefined} subject Canvas position of
* the track, or nothing when it is off screen.
* @param {number} [radiusPx]
*/
export function doubleClickHitsSubject(
click,
subject,
radiusPx = SUBJECT_DOUBLE_CLICK_RADIUS_PX,
) {
if (!click || !subject) return false;
const distance = Math.hypot(click.x - subject.x, click.y - subject.y);
return Number.isFinite(distance) && distance <= radiusPx;
}
/** Where the track `kind` would ride is drawn now, as a world position. */
function subjectWorldPosition(kind) {
if (kind !== 'aircraft') return null;
return (
this.viewer.trackedEntity?.position?.getValue?.(
this.viewer.clock?.currentTime,
) ?? null
);
}
/** The followed track's canvas position now, or null off screen. */
function subjectCanvasPosition() {
const kind = this.resolveEntryKind();
const world = kind ? subjectWorldPosition.call(this, kind) : null;
const scene = this.viewer.scene;
if (!world || typeof scene?.cartesianToCanvasCoordinates !== 'function')
return null;
return (
scene.cartesianToCanvasCoordinates(world, new Cesium.Cartesian2()) ?? null
);
}
/**
* Enter first person when the followed track itself is double-clicked: the
* same entry, and the same gates, as C and the entry buttons. A
* double-click anywhere else, or one a tool owns (drawing ends a shape with
* one), is left alone.
* @param {{x: number, y: number}|null|undefined} position Canvas position.
* @returns {boolean} True when it landed on the followed track, entered or
* refused by the gate: the caller does nothing else with it.
*/
export function onSceneDoubleClick(position) {
if (this.active || this.destroyed || !isPointerFree()) return false;
if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y))
return false;
if (!doubleClickHitsSubject(position, subjectCanvasPosition.call(this)))
return false;
this.enter();
return true;
}
/**
* Take the scene's double-click through the Cockpit first. Cesium's Viewer
* installs its own (pickAndTrackObject), which untracks whenever the pick is
* not an Entity: on a followed aircraft's model, a primitive, it would drop
* the very track the double-click means to ride. A double-click on the
* followed track is the Cockpit's; any other goes on to Cesium's.
* @returns {() => void} Puts Cesium's handler back, unless another owner
* (the draw tool borrows it) has replaced ours since.
*/
export function routeSceneDoubleClick() {
const handler = this.viewer.screenSpaceEventHandler;
if (typeof handler?.setInputAction !== 'function') return () => {};
const type = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
const stock = handler.getInputAction?.(type) ?? null;
const routed = (event) => {
if (this.onSceneDoubleClick(event?.position)) return;
stock?.(event);
};
handler.setInputAction(routed, type);
return () => {
if (handler.isDestroyed?.() || handler.getInputAction?.(type) !== routed)
return;
if (stock) handler.setInputAction(stock, type);
else handler.removeInputAction(type);
};
}
/**
* The Cockpit's capture-phase keydown: Escape and C.
* @param {KeyboardEvent} event
*/
export function onKeyDown(event) {
if (this.destroyed) return false;
if (event.repeat || event.isComposing) return;
+18
View File
@@ -144,3 +144,21 @@ export function formatCockpitWindDirection(value) {
const degrees = roundHeadingDegrees(value);
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
}
/**
* The Cockpit entry for each subject it can ride: an aircraft's cockpit.
* Both entry buttons (Contacts and the Intel HUD readout) show it.
*/
const COCKPIT_ENTRY_FACES = Object.freeze({
aircraft: Object.freeze({ icon: 'flight', label: msg('COCKPIT') }),
});
/**
* The entry's glyph and localized label for the subject it would ride.
* @param {'aircraft'|null|undefined} kind
* @returns {{icon: string, label: string}}
*/
export function cockpitEntryFace(kind) {
const face = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft;
return { icon: face.icon, label: t(face.label) };
}
+54 -11
View File
@@ -62,7 +62,31 @@ export function pushCockpitSignal(key, tone, title, detail, target = null) {
this.publishCockpitSignals();
}
export function updateCockpitSignals(snapshot, unknownCount) {
/**
* Take a status row off the list, as when the feed it warned about is back.
* @param {string} key The row's key.
*/
export function dropCockpitSignal(key) {
this.signalSignatures.delete(key);
const kept = this.signalItems.filter((item) => item.key !== key);
if (kept.length === this.signalItems.length) return;
this.signalItems = kept;
this.publishCockpitSignals();
}
/**
* Rebuild the rows from a Contacts snapshot: the nearest flights, a row for
* unknown inputs and, first, a row naming an air feed that is off.
* @param {object} snapshot `militaryAwareness.getContextSnapshot()` result.
* @param {number} unknownCount Cohorts whose count is unknown.
* @param {?{title: string, detail: string}} [partialPicture] The feed-off
* row (see `readPartialPicture`), or null with every air feed on.
*/
export function updateCockpitSignals(
snapshot,
unknownCount,
partialPicture = null,
) {
const previous = new Map(this.signalItems.map((item) => [item.key, item]));
const contacts = [];
const subject = snapshot.subject;
@@ -110,7 +134,9 @@ export function updateCockpitSignals(snapshot, unknownCount) {
}
}
contacts.sort((a, b) => a.distanceM - b.distanceM);
const nextItems = contacts.slice(0, 5).map((item) => ({
// A feed-off row leads, so the list never reads as the whole picture.
const reserved = partialPicture ? 1 : 0;
const nextItems = contacts.slice(0, 5 - reserved).map((item) => ({
...item,
timestamp:
previous.get(item.key)?.timestamp || snapshot.evaluatedAt || Date.now(),
@@ -120,18 +146,35 @@ export function updateCockpitSignals(snapshot, unknownCount) {
.filter((cohort) => cohort.count === null)
.map((cohort) => cohort.source)
.join(' · ');
nextItems.splice(4, Math.max(0, nextItems.length - 4), {
key: 'input-status',
nextItems.splice(
4 - reserved,
Math.max(0, nextItems.length - 4 + reserved),
{
key: 'input-status',
tone: 'warning',
title: tn(
unknownCount,
'{count} INPUT UNKNOWN',
'{count} INPUTS UNKNOWN',
),
detail: sources || t('SOURCE STATUS UNAVAILABLE'),
target: null,
timestamp:
previous.get('input-status')?.timestamp ||
snapshot.evaluatedAt ||
Date.now(),
},
);
}
if (partialPicture) {
nextItems.unshift({
key: 'feed-status',
tone: 'warning',
title: tn(
unknownCount,
'{count} INPUT UNKNOWN',
'{count} INPUTS UNKNOWN',
),
detail: sources || t('SOURCE STATUS UNAVAILABLE'),
title: partialPicture.title,
detail: partialPicture.detail,
target: null,
timestamp:
previous.get('input-status')?.timestamp ||
previous.get('feed-status')?.timestamp ||
snapshot.evaluatedAt ||
Date.now(),
});
+29 -2
View File
@@ -17,6 +17,30 @@ export function readAircraftInfo() {
});
}
/**
* The layer the active view rides: the aircraft's own layer. It is read from
* the tracked entity's id once the layer has dropped its tracking, so a layer
* turned off still names itself.
* @returns {'flights'|'military'|null}
*/
export function readSubjectLayerId() {
if (!this.active) return null;
const layerId = this.readAircraftInfo()?.layerId;
if (layerId) return layerId;
const [prefix] = String(this.trackedEntity?.gevTrackedId || '').split(':');
return ['flights', 'military'].includes(prefix) ? prefix : null;
}
/**
* The subject entry would take: a Cesium-tracked aircraft.
* @returns {'aircraft'|null}
*/
export function resolveEntryKind() {
return this.readAircraftInfo() && this.viewer.trackedEntity?.position
? 'aircraft'
: null;
}
export function dispatchCockpitModeChanged(active, info = null) {
const subjectId = active
? String(info?.icao24 || '')
@@ -247,9 +271,12 @@ export function exit({ restoreTracking = true } = {}) {
this.restoreTrackingFrame(entity);
}
this.syncEntry();
// Back to whichever entry was used: the Contacts button or the readout's.
const readoutEntry = document.getElementById('cockpit-entry-readout');
const restoreTarget =
this.entryFocusOrigin === this.entry
? this.entry
this.entryFocusOrigin === this.entry ||
(readoutEntry && this.entryFocusOrigin === readoutEntry)
? this.entryFocusOrigin
: this.entry || this.entryFocusOrigin;
this.entryFocusOrigin = null;
// The entry shows when Vue flushes the presence; read it after.
+6 -10
View File
@@ -1,7 +1,6 @@
import { t, translateKnown } from '../i18n/index.js';
import {
SPACE_MISSIONS_LAYER_BLOCK_REASON,
cockpitEntryAllowed,
isExplicitUserIntentOrigin,
parseContextLayerBlockReason,
recordContextRestoreExplicitChange,
@@ -245,15 +244,12 @@ export function _handleContextLayerChange(change) {
);
}
}
if (
this.cockpitView?.active &&
!cockpitEntryAllowed({
contextMode: this._contextMode,
contextModeChanging: this._contextModeChanging,
flightsEnabled: !!this._dataManager?.isEnabled('flights'),
militaryEnabled: !!this._dataManager?.isEnabled('military'),
})
) {
// Turning off the layer the view rides ends it; every other layer may be
// turned on or off freely during a session.
const riddenLayerId = this.cockpitView?.active
? this.cockpitView.readSubjectLayerId?.()
: null;
if (riddenLayerId && !this._dataManager?.isEnabled(riddenLayerId)) {
this.cockpitView.exit({ restoreTracking: false });
}
this._syncContextModeButtons();
+6 -3
View File
@@ -18,7 +18,10 @@ export async function _selectContextMode(
this._contextModeChanging = true;
this._syncContextModeButtons();
try {
if (mode !== 'flights' && this.cockpitView?.active) {
// Cockpit does not need Contacts, so leaving it keeps the view; the
// restore below ends it only by turning off the layer it rides. Another
// mode takes the camera, so switching to one ends it.
if (mode && mode !== 'flights' && this.cockpitView?.active) {
this.cockpitView.exit({ restoreTracking: false });
}
if (!mode) {
@@ -220,8 +223,8 @@ export async function _deactivateContextForLayerChange({
this._contextMode = null;
this._contextModeEntryIntent = null;
this._contextModeReplacementIntent = null;
if (this.cockpitView?.active)
this.cockpitView.exit({ restoreTracking: false });
// A layer turned off ends Contacts but not the Cockpit: the view ends only
// when the layer it rides goes off (see _handleContextLayerChange).
this._syncContextModeButtons();
try {
await this._restoreContextSession({ notificationToken });
+30
View File
@@ -33,6 +33,36 @@
color: #00dcff;
font-size: 18px;
}
/* The same entry under the selected vehicle's Intel HUD readout. The HUD
lets clicks through, so the button takes them back. */
#intel-hud #cockpit-entry-readout {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 6px;
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--hud-border);
border-radius: 6px;
background: rgba(2, 12, 18, 0.72);
font-size: 10px;
letter-spacing: 0.14em;
pointer-events: auto;
cursor: pointer;
}
#intel-hud #cockpit-entry-readout[hidden] {
display: none;
}
#intel-hud #cockpit-entry-readout:hover,
#intel-hud #cockpit-entry-readout:focus-visible {
border-color: var(--hud-color);
outline: none;
}
#intel-hud #cockpit-entry-readout .material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-size: 16px;
text-transform: none;
}
#cockpit-entry kbd {
padding: 3px 5px;
border: 1px solid rgba(220, 238, 255, 0.25);
+5 -2
View File
@@ -14,11 +14,14 @@ import MissionDetail from './panels/MissionDetail.vue';
import MissionRoster from './panels/MissionRoster.vue';
import RadioPanel from './panels/RadioPanel.vue';
import UiButton from '../kit/UiButton.vue';
import { cockpitEntryFace } from '../cockpitPresentation.js';
// ContextControls presents the mode switch and the nearby sites search and
// publishes them; until it does, the panel shows its initial markup.
const { view: ctx } = storeToRefs(useContextStore());
const { presence: cockpit } = storeToRefs(useCockpitStore());
// The entry rides the tracked aircraft (cockpitEntryFace).
const entry = computed(() => cockpitEntryFace('aircraft'));
// While the Bhote Koshi event is on, its panel is the rail's only content.
const { panel: bhotePanel } = storeToRefs(useBhoteKoshiPanelStore());
// The Scene runtime chip SceneControls presents while a scene plays.
@@ -56,8 +59,8 @@ const sceneRuntime = computed(() => scenePanel.value?.runtime ?? '');
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" :hidden="!ctx?.flightsActive">
<div class="global-context-actions" :aria-label="t('Contact Context actions')">
<button id="cockpit-entry" type="button" :hidden="!cockpit?.entryAvailable">
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
<span>{{ t('COCKPIT') }}</span>
<span class="material-symbols-outlined" aria-hidden="true">{{ entry.icon }}</span>
<span>{{ entry.label }}</span>
</button>
<button id="installations-search-btn" class="panel-layer-toggle" type="button" :aria-disabled="ctx && String(ctx.installationsBusy)" :aria-busy="ctx && String(ctx.installationsBusy)">{{ t('SEARCH NEARBY SITES') }}</button>
<UiButton id="tr3b-toggle" class="tr3b-toggle" :pressed="!!cockpit?.tr3b?.converted" :hidden="!cockpit?.tr3b" :aria-label="t('Reclassify tracked contact as TR-3B')" :title="cockpit?.tr3b?.converted ? t('Restore real aircraft') : t('Reclassify as TR-3B')">🛸</UiButton>
+12
View File
@@ -2,6 +2,8 @@
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useHudStore } from '../stores/hud.js';
import { useCockpitStore } from '../stores/cockpit.js';
import { cockpitEntryFace } from '../cockpitPresentation.js';
// The readouts, visibility, layout variant and colors IntelHUD publishes.
const { hud } = storeToRefs(useHudStore());
@@ -10,6 +12,12 @@ const { hud } = storeToRefs(useHudStore());
// right after IntelHUD is built.
const EMPTY = Object.freeze({ vessel: {}, sensor: [], recShown: null });
const view = computed(() => hud.value ?? EMPTY);
// The selected vehicle's readout offers the same entry as the Contacts
// button, so first person needs no Contacts panel. The Cockpit controller
// answers its clicks.
const { presence: cockpit } = storeToRefs(useCockpitStore());
// The entry rides the tracked aircraft (cockpitEntryFace).
const entry = computed(() => cockpitEntryFace('aircraft'));
</script>
<template>
@@ -46,6 +54,10 @@ const view = computed(() => hud.value ?? EMPTY);
<div class="hud-readout"><div id="hud-gsd">{{ view.gsd }}</div></div>
<div class="hud-readout"><div id="hud-alt">{{ view.alt }}</div></div>
<div class="hud-readout"><div id="hud-ais-vessel" :class="['hud-ais-vessel', { active: view.vessel.active }]">{{ view.vessel.text }}</div></div>
<button id="cockpit-entry-readout" type="button" :hidden="!cockpit?.entryAvailable">
<span class="material-symbols-outlined" aria-hidden="true">{{ entry.icon }}</span>
<span>{{ entry.label }}</span>
</button>
</div>
<div class="hud-bracket">┘</div>
</div>
+2 -1
View File
@@ -55,7 +55,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// Voice geometry adds resolve_area, find_imagery, osm_query, the analyst
// area scopes and the osm-places layer; natural-region candidates and the
// tightened area tool wording follow review.
'd0c5ad733670972713a54cb10676c42b7c6e78834bfff52b473c0e8a45e742c3',
// control_cockpit states the own-layer entry rule (Contacts not required).
'9522ff6f6d640bede81c51cea6b9601439b08becc5bc20b7355aec24feb17e8d',
);
});