mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
Merge remote-tracking branch 'origin/main' into pr/wildlife-sightings
This commit is contained in:
commit
bd99000433
44 files changed
+1420
-139
No files matched your search
@@ -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'/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -98,6 +98,7 @@ function installAwarenessRuntime({
|
||||
// status its module never emits guards nothing. See the settled-but-fetching
|
||||
// tests below, which use militaryInstallations' genuine first-fetch readout.
|
||||
layerStats = {},
|
||||
subscribe = undefined,
|
||||
} = {}) {
|
||||
const originalWindow = globalThis.window;
|
||||
const originalDocument = globalThis.document;
|
||||
@@ -215,6 +216,7 @@ function installAwarenessRuntime({
|
||||
setEnabled,
|
||||
getLayerLifecycleState,
|
||||
layers,
|
||||
...(subscribe ? { subscribe } : {}),
|
||||
});
|
||||
militaryAwarenessLayer.setParams({ passive: true });
|
||||
militaryAwarenessLayer.init(viewer);
|
||||
@@ -383,6 +385,54 @@ test('Contacts does not claim a dependency already enabling for the user', async
|
||||
}
|
||||
});
|
||||
|
||||
test('a dependency the user adopts stays on when Contacts turns off', async () => {
|
||||
// A direct selection, or the Cockpit riding one of its aircraft, promotes a
|
||||
// layer Contacts turned on into the user's own (adoptLayerVisibility). The
|
||||
// coordinator must give it up, or its OFF release tears the layer down —
|
||||
// and the track or the Cockpit with it — before Context restores it.
|
||||
const calls = [];
|
||||
const subscribers = new Set();
|
||||
const runtime = installAwarenessRuntime({
|
||||
isEnabled: () => false,
|
||||
isEffectivelyEnabled: () => false,
|
||||
setEnabled: (layerId, enabled) => {
|
||||
calls.push({ layerId, enabled });
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
subscribe: (listener) => {
|
||||
subscribers.add(listener);
|
||||
return () => subscribers.delete(listener);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
militaryAwarenessLayer.setParams({ passive: false });
|
||||
await nextTurn();
|
||||
for (const listener of subscribers)
|
||||
listener({
|
||||
type: 'visibility',
|
||||
layerId: 'flights',
|
||||
enabled: true,
|
||||
origin: 'user',
|
||||
adopted: true,
|
||||
adoptedFromSelection: true,
|
||||
});
|
||||
await militaryAwarenessLayer.disable();
|
||||
const released = calls
|
||||
.filter(({ enabled }) => !enabled)
|
||||
.map(({ layerId }) => layerId)
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
released,
|
||||
AWARENESS_DEPENDENCIES.filter((layerId) => layerId !== 'flights').sort(),
|
||||
'the adopted layer is the user\'s now; the rest are still released',
|
||||
);
|
||||
} finally {
|
||||
runtime.restore();
|
||||
}
|
||||
assert.equal(subscribers.size, 0, 'destroy drops the manager subscription');
|
||||
});
|
||||
|
||||
test('operational awareness becomes ready after aircraft while deferred sources keep loading', async () => {
|
||||
let enabled = false;
|
||||
let releaseAircraft;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "уз",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Vendored
+1
@@ -23,6 +23,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
attachDataManager(dataManager) {
|
||||
layerState.dataManager = dataManager;
|
||||
parts.dependencies.watchAdoptions(dataManager);
|
||||
},
|
||||
|
||||
setParams(params = {}) {
|
||||
|
||||
@@ -79,6 +79,25 @@ export function createDependencies({
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Give up a dependency the user adopts. A direct selection, or the Cockpit
|
||||
* riding one of its aircraft, promotes a layer Contacts turned on into the
|
||||
* user's own (`DataLayerManager.adoptLayerVisibility`). Releasing it when
|
||||
* Contacts goes off would tear down that track or Cockpit before the
|
||||
* Context restore could put the layer back.
|
||||
*/
|
||||
function relinquishAdoptedDependency(change) {
|
||||
if (change?.type === 'visibility' && change.adopted && change.enabled)
|
||||
layerState.ownedDependencies.delete(change.layerId);
|
||||
}
|
||||
|
||||
/** Follow one manager's adoptions; null stops following. */
|
||||
function watchAdoptions(dataManager) {
|
||||
layerState.unwatchAdoptions?.();
|
||||
layerState.unwatchAdoptions =
|
||||
dataManager?.subscribe?.(relinquishAdoptedDependency) || null;
|
||||
}
|
||||
|
||||
function activateOperationalContext() {
|
||||
const activationId = ++layerState.activationId;
|
||||
layerState.autoFocusRetryPending = false;
|
||||
@@ -113,6 +132,7 @@ export function createDependencies({
|
||||
refreshAfterDeferredDependency,
|
||||
enableDependencies,
|
||||
releaseOwnedDependencies,
|
||||
watchAdoptions,
|
||||
activateOperationalContext,
|
||||
};
|
||||
}
|
||||
@@ -340,6 +340,7 @@ export function createLifecycle({
|
||||
layerState.clearListener = null;
|
||||
layerState.subjectClearListener = null;
|
||||
layerState.viewer = null;
|
||||
parts.dependencies.watchAdoptions(null);
|
||||
layerState.dataManager = null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -26,6 +26,8 @@ export function createState({ services }) {
|
||||
// layers activate only after Contacts is explicitly selected.
|
||||
passive: true,
|
||||
ownedDependencies: new Set(),
|
||||
/** Stops following the manager's adoptions (see watchAdoptions). */
|
||||
unwatchAdoptions: null,
|
||||
subject: null,
|
||||
results: null,
|
||||
visual: null,
|
||||
|
||||
@@ -12,6 +12,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;
|
||||
@@ -67,7 +70,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)
|
||||
|
||||
@@ -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('; '),
|
||||
);
|
||||
});
|
||||
+26
-5
@@ -19,6 +19,14 @@ import {
|
||||
COCKPIT_GROUND_WAIT_TIMEOUT_MS,
|
||||
} from './cockpitPresentation.js';
|
||||
|
||||
/** The aircraft eye: just ahead of and above the reported position. */
|
||||
const AIRCRAFT_CAMERA_PROFILE = Object.freeze({
|
||||
forwardM: COCKPIT_FORWARD_OFFSET_M,
|
||||
upM: COCKPIT_UP_OFFSET_M,
|
||||
pitchDeg: COCKPIT_VIEW_PITCH_DEG,
|
||||
minClearanceM: COCKPIT_MIN_GROUND_CLEARANCE_M,
|
||||
});
|
||||
|
||||
export function update() {
|
||||
if (this.destroyed) return false;
|
||||
if (!this.active) {
|
||||
@@ -75,7 +83,20 @@ export function update() {
|
||||
COCKPIT_HEADING_SLEW_DPS * dtSec,
|
||||
);
|
||||
}
|
||||
placeCamera.call(this, target, info, nowMs, dtSec, AIRCRAFT_CAMERA_PROFILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Place the camera for one update: advance the inertial anchor, clamp it and
|
||||
* the eye against the rendered surface, and look along the slewed heading.
|
||||
* @param {Cesium.Cartesian3} target Authoritative subject position.
|
||||
* @param {object} info Subject info.
|
||||
* @param {number} nowMs Frame time.
|
||||
* @param {number} dtSec Seconds since the previous camera update.
|
||||
* @param {{forwardM: number, upM: number, pitchDeg: number,
|
||||
* minClearanceM: number}} profile Eye offsets for the subject kind.
|
||||
*/
|
||||
function placeCamera(target, info, nowMs, dtSec, profile) {
|
||||
if (!this.cockpitAnchorValid) {
|
||||
Cesium.Cartesian3.clone(target, this.cockpitAnchor);
|
||||
this.cockpitAnchorValid = true;
|
||||
@@ -89,7 +110,7 @@ export function update() {
|
||||
// 15/30-second interpolation and per-frame cache without exposing its
|
||||
// sample-boundary corrections to the camera.
|
||||
const headingRad = Cesium.Math.toRadians(this.heading ?? 0);
|
||||
const pitchRad = Cesium.Math.toRadians(COCKPIT_VIEW_PITCH_DEG);
|
||||
const pitchRad = Cesium.Math.toRadians(profile.pitchDeg);
|
||||
const speedMps = Number.isFinite(info.velocityMps)
|
||||
? Math.max(0, info.velocityMps)
|
||||
: 0;
|
||||
@@ -248,7 +269,7 @@ export function update() {
|
||||
const minimumAnchorHeightM = cockpitGroundSafeHeight(
|
||||
anchorCartographic.height,
|
||||
cockpitFloorM,
|
||||
COCKPIT_MIN_GROUND_CLEARANCE_M - COCKPIT_UP_OFFSET_M,
|
||||
profile.minClearanceM - profile.upM,
|
||||
);
|
||||
if (minimumAnchorHeightM !== anchorCartographic.height) {
|
||||
anchorCartographic.height = minimumAnchorHeightM;
|
||||
@@ -297,7 +318,7 @@ export function update() {
|
||||
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
this.scratchHorizontal,
|
||||
COCKPIT_FORWARD_OFFSET_M,
|
||||
profile.forwardM,
|
||||
this.scratchCamera,
|
||||
);
|
||||
Cesium.Cartesian3.add(
|
||||
@@ -309,7 +330,7 @@ export function update() {
|
||||
Cesium.Cartesian3.normalize(this.scratchTarget, this.scratchTarget);
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
this.scratchTarget,
|
||||
COCKPIT_UP_OFFSET_M,
|
||||
profile.upM,
|
||||
this.scratchTarget,
|
||||
);
|
||||
Cesium.Cartesian3.add(
|
||||
@@ -338,7 +359,7 @@ export function update() {
|
||||
const safeHeightM = cockpitGroundSafeHeight(
|
||||
cameraCartographic.height,
|
||||
cockpitFloorM,
|
||||
COCKPIT_MIN_GROUND_CLEARANCE_M,
|
||||
profile.minClearanceM,
|
||||
);
|
||||
if (safeHeightM !== cameraCartographic.height) {
|
||||
cameraCartographic.height = safeHeightM;
|
||||
|
||||
@@ -25,6 +25,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;
|
||||
@@ -53,7 +74,11 @@ 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) {
|
||||
// Contacts just went off: the rows its snapshot built stop being
|
||||
// measured, so they go before the standby rows replace them.
|
||||
if (this.presence.context) this.dropContextSnapshotSignals?.();
|
||||
this.showContextCard(false);
|
||||
this.contextLayoutStamp = null;
|
||||
this.pushCockpitSignal(
|
||||
@@ -62,6 +87,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;
|
||||
}
|
||||
|
||||
@@ -173,19 +206,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();
|
||||
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
updateContext,
|
||||
} from './cockpitContext.js';
|
||||
import { createContextState } from './cockpitContextState.js';
|
||||
import {
|
||||
dropCockpitSignal,
|
||||
dropContextSnapshotSignals,
|
||||
publishCockpitSignals,
|
||||
pushCockpitSignal,
|
||||
updateCockpitSignals,
|
||||
} from './cockpitSignals.js';
|
||||
import { syncWeatherToggle } from './cockpitInstruments.js';
|
||||
import { setContextCollapsed } from './cockpitLayout.js';
|
||||
import { useCockpitStore } from './stores/cockpit.js';
|
||||
@@ -248,6 +255,119 @@ 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']);
|
||||
});
|
||||
|
||||
/** The fixture's owner with the real Live Signals presenters. */
|
||||
function withSignals(owner) {
|
||||
const published = [];
|
||||
Object.assign(owner, {
|
||||
signalItems: [],
|
||||
signalSignatures: new Map(),
|
||||
publishSignals: (rows) => published.push(rows),
|
||||
publishCockpitSignals,
|
||||
pushCockpitSignal,
|
||||
dropCockpitSignal,
|
||||
dropContextSnapshotSignals,
|
||||
updateCockpitSignals,
|
||||
});
|
||||
return () => published.at(-1).map((row) => row.title);
|
||||
}
|
||||
|
||||
test('Contacts going off clears its contact rows and leaves standby', async (t) => {
|
||||
const { owner } = fixture(t, snapshot());
|
||||
const rows = withSignals(owner);
|
||||
owner.services.isLayerEnabled = () => true;
|
||||
updateContext.call(owner, INFO, 0);
|
||||
assert.ok(
|
||||
owner.signalItems.some((item) => item.key.startsWith('flight:')),
|
||||
'Contacts fills the rows',
|
||||
);
|
||||
assert.ok(owner.signalItems.some((item) => item.key === 'input-status'));
|
||||
// Both feeds stay on; only Contacts goes.
|
||||
owner.snapshot = null;
|
||||
updateContext.call(owner, INFO, 0);
|
||||
assert.deepEqual(rows(), ['CONTEXT STANDBY']);
|
||||
updateContext.call(owner, INFO, 0);
|
||||
assert.deepEqual(rows(), ['CONTEXT STANDBY'], 'standby holds still');
|
||||
});
|
||||
|
||||
test('Contacts going off with a feed off keeps the feed row beside standby', async (t) => {
|
||||
const { owner } = fixture(t, snapshot());
|
||||
const rows = withSignals(owner);
|
||||
owner.services.isLayerEnabled = (layerId) => layerId !== 'military';
|
||||
updateContext.call(owner, INFO, 0);
|
||||
owner.snapshot = null;
|
||||
updateContext.call(owner, INFO, 0);
|
||||
assert.deepEqual(rows(), [
|
||||
'MILITARY FEED OFF · PARTIAL PICTURE',
|
||||
'CONTEXT STANDBY',
|
||||
]);
|
||||
});
|
||||
|
||||
test('standby without Contacts keeps the rows the session already shows', async (t) => {
|
||||
const { owner } = fixture(t, null);
|
||||
const rows = withSignals(owner);
|
||||
owner.services.isLayerEnabled = () => true;
|
||||
owner.pushCockpitSignal('track', 'track', 'TRACK ACQUIRED', 'UAL123');
|
||||
updateContext.call(owner, INFO, 0);
|
||||
assert.deepEqual(rows(), ['CONTEXT STANDBY', 'TRACK ACQUIRED']);
|
||||
});
|
||||
|
||||
test('the weather toggle renders its state and the action it offers', async (t) => {
|
||||
const { $, text, owner } = fixture(t);
|
||||
syncWeatherToggle.call(owner, true);
|
||||
|
||||
@@ -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,8 @@ import {
|
||||
relocalizeBrief,
|
||||
} from './cockpitBriefing.js';
|
||||
import {
|
||||
dropCockpitSignal,
|
||||
dropContextSnapshotSignals,
|
||||
handleSignalClick,
|
||||
publishCockpitSignals,
|
||||
pushCockpitSignal,
|
||||
@@ -60,7 +64,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 {
|
||||
@@ -75,6 +83,7 @@ export class CockpitViewController {
|
||||
isUtilityDisclosureOpen = null,
|
||||
onEntered = null,
|
||||
onExited = null,
|
||||
onRide = null,
|
||||
restoreTrackingFrame = null,
|
||||
publish = {},
|
||||
} = {},
|
||||
@@ -114,6 +123,7 @@ export class CockpitViewController {
|
||||
this.onCameraTakeover = onCameraTakeover;
|
||||
this.onEntered = onEntered;
|
||||
this.onExited = onExited;
|
||||
this.onRide = onRide;
|
||||
this.restoreTrackingFrame =
|
||||
typeof restoreTrackingFrame === 'function'
|
||||
? restoreTrackingFrame
|
||||
@@ -205,6 +215,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 +289,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 +394,19 @@ 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);
|
||||
}
|
||||
dropContextSnapshotSignals() {
|
||||
return dropContextSnapshotSignals.call(this);
|
||||
}
|
||||
updateCockpitSignals(snapshot, unknownCount, partialPicture) {
|
||||
return updateCockpitSignals.call(
|
||||
this,
|
||||
snapshot,
|
||||
unknownCount,
|
||||
partialPicture,
|
||||
);
|
||||
}
|
||||
scheduleContextLayout() {
|
||||
return scheduleContextLayout.call(this);
|
||||
@@ -392,6 +426,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);
|
||||
|
||||
@@ -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,23 +75,29 @@ 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(),
|
||||
onExited: () => this.exitPanels(),
|
||||
onRide: (layerId) => this._adoptRiddenLayer(layerId),
|
||||
restoreTrackingFrame: (entity) => {
|
||||
const [layerId, ...idParts] = String(entity?.gevTrackedId || '').split(
|
||||
':',
|
||||
@@ -115,6 +124,44 @@ 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);
|
||||
}
|
||||
/**
|
||||
* Make the ridden aircraft's layer the user's own while Contacts is on.
|
||||
* Contacts may have turned that layer on, and it releases the layers it
|
||||
* owns when it goes off; the Cockpit must outlive Contacts, so riding an
|
||||
* aircraft adopts its layer as a direct selection does. Contacts then stops
|
||||
* owning it and the Context restore keeps it (session `userAdded`).
|
||||
* @param {string|null} layerId The ridden aircraft's layer.
|
||||
*/
|
||||
_adoptRiddenLayer(layerId) {
|
||||
if (!COCKPIT_AIRCRAFT_LAYER_IDS.includes(layerId)) return;
|
||||
if (!this._isLayerEnabled('military-awareness')) return;
|
||||
this._dataManager?.adoptLayerVisibility?.(layerId, true, {
|
||||
origin: 'user',
|
||||
adoptedFromSelection: true,
|
||||
});
|
||||
}
|
||||
/** 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 +176,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 +312,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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
// 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, Contacts ending
|
||||
// keeps the ridden layer it had turned on, 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 { DataLayerManager } from '../data/manager.js';
|
||||
import { createDependencies } from '../layers/awareness/dependencies.js';
|
||||
import { CockpitCoordinator } from './cockpitCoordinator.js';
|
||||
import { ContextControls } from './contextControls.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,
|
||||
dataManager: suppliedManager = null,
|
||||
readContext = null,
|
||||
} = {},
|
||||
) {
|
||||
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 = suppliedManager || {
|
||||
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 && dataManager.isEnabled(layerId)
|
||||
? tracked.info
|
||||
: null;
|
||||
const entityFor = (subject) => ({
|
||||
position: { getValue: () => null },
|
||||
show: true,
|
||||
gevTrackedId: subject ? `${subject.layerId}:${subject.info.icao24}` : '',
|
||||
});
|
||||
const entities = new Set();
|
||||
const entity = entityFor(tracked);
|
||||
entities.add(entity);
|
||||
const listeners = () => ({ addEventListener: () => () => {} });
|
||||
const viewer = {
|
||||
trackedEntity: tracked ? entity : undefined,
|
||||
clock: { currentTime: null },
|
||||
camera: { cancelFlight() {}, frustum: { fov: 1, aspectRatio: 1 } },
|
||||
entities: { contains: (candidate) => entities.has(candidate) },
|
||||
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: 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' },
|
||||
);
|
||||
};
|
||||
/** Track another aircraft, as Contacts navigation does mid-session. */
|
||||
const retrack = (subject) => {
|
||||
tracked = subject;
|
||||
viewer.trackedEntity = entityFor(subject);
|
||||
entities.add(viewer.trackedEntity);
|
||||
};
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
return { $, coordinator, cockpit, store, layers, disable, retrack, 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');
|
||||
});
|
||||
|
||||
const AIR_AND_SEA = [
|
||||
'flights',
|
||||
'military',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
];
|
||||
|
||||
/** A source layer that settles at once, as the data manager drives it. */
|
||||
function sourceLayer(id) {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
icon: '',
|
||||
source: 'test',
|
||||
updateInterval: -1,
|
||||
init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
update() {},
|
||||
getStats: () => ({ count: 0, lastUpdate: null }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Contacts as the manager sees it: a coordinator whose real dependency
|
||||
* ownership (src/layers/awareness/dependencies.js) turns on the air and sea
|
||||
* layers it finds off and releases them when it goes off.
|
||||
*/
|
||||
function contactsCoordinator() {
|
||||
const state = {
|
||||
dataManager: null,
|
||||
enabled: false,
|
||||
passive: true,
|
||||
activationId: 0,
|
||||
ownedDependencies: new Set(),
|
||||
subject: null,
|
||||
autoFocusRetryPending: false,
|
||||
};
|
||||
const dependencies = createDependencies({
|
||||
state,
|
||||
services: {},
|
||||
parts: { subject: {}, focus: {} },
|
||||
source: null,
|
||||
});
|
||||
return {
|
||||
id: 'military-awareness',
|
||||
name: 'Contacts',
|
||||
icon: '',
|
||||
source: 'test',
|
||||
showInTogglePanel: false,
|
||||
updateInterval: -1,
|
||||
attachDataManager(dataManager) {
|
||||
state.dataManager = dataManager;
|
||||
dependencies.watchAdoptions(dataManager);
|
||||
},
|
||||
setParams(params = {}) {
|
||||
if (typeof params.passive === 'boolean') state.passive = params.passive;
|
||||
},
|
||||
getParams: () => ({ passive: state.passive }),
|
||||
init() {},
|
||||
enable() {
|
||||
state.enabled = true;
|
||||
if (!state.passive) return dependencies.enableDependencies();
|
||||
return true;
|
||||
},
|
||||
disable() {
|
||||
state.enabled = false;
|
||||
state.passive = true;
|
||||
return dependencies.releaseOwnedDependencies(++state.activationId);
|
||||
},
|
||||
update() {},
|
||||
getStats: () => ({ count: 0, lastUpdate: null }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Cockpit beside the real Context owner and data manager, so a Contacts
|
||||
* exit runs its whole restore: the coordinator's releases, then the snapshot.
|
||||
*/
|
||||
async function liveFixture(t, { enabled = [], tracked = null } = {}) {
|
||||
const manager = new DataLayerManager({});
|
||||
for (const layerId of AIR_AND_SEA) manager.register(sourceLayer(layerId));
|
||||
const coordinatorModule = contactsCoordinator();
|
||||
manager.register(coordinatorModule);
|
||||
coordinatorModule.attachDataManager(manager);
|
||||
for (const layerId of enabled) await manager.setEnabled(layerId, true);
|
||||
let cockpit = null;
|
||||
const controls = new ContextControls({
|
||||
elements: {},
|
||||
installations: {},
|
||||
actions: {
|
||||
getCockpit: () => cockpit,
|
||||
claimVisualAuthority() {},
|
||||
showToast() {},
|
||||
setClearBusy() {},
|
||||
syncDetection() {},
|
||||
scheduleLayout() {},
|
||||
refreshRadio() {},
|
||||
setPanelCollapsed() {},
|
||||
},
|
||||
});
|
||||
controls.connect(manager);
|
||||
t.after(() => {
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
});
|
||||
const base = fixture(t, {
|
||||
tracked,
|
||||
dataManager: manager,
|
||||
readContext: () => controls.getContextModeState(),
|
||||
});
|
||||
cockpit = base.cockpit;
|
||||
/** Let every Context reaction the last change started settle. */
|
||||
const settle = async () => {
|
||||
for (let turn = 0; turn < 5; turn += 1) {
|
||||
await controls._waitForContextLayerSettlement();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
};
|
||||
return { ...base, manager, controls, settle };
|
||||
}
|
||||
|
||||
const on = (manager) =>
|
||||
[...AIR_AND_SEA, 'military-awareness'].filter((layerId) =>
|
||||
manager.isEnabled(layerId),
|
||||
);
|
||||
|
||||
test('Contacts ending in a Cockpit session keeps the ridden layer it had turned on', async (t) => {
|
||||
for (const [label, endContacts] of [
|
||||
[
|
||||
'Military Flights off',
|
||||
({ manager }) =>
|
||||
manager.setEnabled('military', false, { origin: 'user' }),
|
||||
],
|
||||
[
|
||||
'Contacts off by its own layer',
|
||||
({ manager }) =>
|
||||
manager.setEnabled('military-awareness', false, { origin: 'user' }),
|
||||
],
|
||||
[
|
||||
'Contacts off from its chooser',
|
||||
({ controls }) => controls._selectContextMode(null),
|
||||
],
|
||||
]) {
|
||||
await t.test(label, async (t) => {
|
||||
// Live Flights is off on the map; Contacts turns it on as its own.
|
||||
const live = await liveFixture(t, {
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
});
|
||||
const { manager, controls, cockpit, settle } = live;
|
||||
assert.equal(await controls._selectContextMode('flights'), true);
|
||||
await settle();
|
||||
assert.deepEqual(on(manager), [...AIR_AND_SEA, 'military-awareness']);
|
||||
assert.equal(cockpit.enter(), true);
|
||||
await endContacts(live);
|
||||
await settle();
|
||||
assert.equal(controls.getContextModeState().mode, null, 'Contacts ended');
|
||||
assert.equal(cockpit.active, true, 'the Cockpit rides on');
|
||||
assert.deepEqual(
|
||||
on(manager),
|
||||
['flights'],
|
||||
'only the ridden layer outlives the pre-Contacts restore',
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('a ride handed to an aircraft on a layer Contacts turned on keeps that layer', async (t) => {
|
||||
const live = await liveFixture(t, {
|
||||
enabled: ['flights'],
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
});
|
||||
const { manager, controls, cockpit, retrack, settle } = live;
|
||||
assert.equal(await controls._selectContextMode('flights'), true);
|
||||
await settle();
|
||||
assert.equal(cockpit.enter(), true);
|
||||
// NEXT hands the view to a military aircraft; Contacts turned its layer on.
|
||||
retrack({ layerId: 'military', info: MILITARY });
|
||||
assert.equal(cockpit._adoptTrackedEntity(performance.now()), true);
|
||||
assert.equal(cockpit.readSubjectLayerId(), 'military');
|
||||
await manager.setEnabled('ais-live-vessels', false, { origin: 'user' });
|
||||
await settle();
|
||||
assert.equal(controls.getContextModeState().mode, null);
|
||||
assert.equal(cockpit.active, true);
|
||||
assert.deepEqual(on(manager), ['flights', 'military']);
|
||||
});
|
||||
|
||||
test('the ridden layer still ends the Cockpit while Contacts is on', async (t) => {
|
||||
const live = await liveFixture(t, {
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
});
|
||||
const { manager, controls, cockpit, settle } = live;
|
||||
assert.equal(await controls._selectContextMode('flights'), true);
|
||||
await settle();
|
||||
assert.equal(cockpit.enter(), true);
|
||||
await manager.setEnabled('flights', false, { origin: 'user' });
|
||||
await settle();
|
||||
assert.equal(cockpit.active, false);
|
||||
assert.equal(manager.isEnabled('flights'), false);
|
||||
});
|
||||
|
||||
test('without a Cockpit, ending Contacts restores the layers it found', async (t) => {
|
||||
const live = await liveFixture(t, {
|
||||
tracked: { layerId: 'flights', info: CIVIL },
|
||||
});
|
||||
const { manager, controls, settle } = live;
|
||||
assert.equal(await controls._selectContextMode('flights'), true);
|
||||
await settle();
|
||||
await manager.setEnabled('military', false, { origin: 'user' });
|
||||
await settle();
|
||||
assert.equal(controls.getContextModeState().mode, null);
|
||||
assert.deepEqual(on(manager), [], 'Live Flights goes back off');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
+75
-11
@@ -62,7 +62,52 @@ 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();
|
||||
}
|
||||
|
||||
/** Whether a row is one a Contacts snapshot builds, with its status rows. */
|
||||
const isContextSnapshotRow = (key) =>
|
||||
key.startsWith('flight:') ||
|
||||
['input-status', 'feed-status', 'context-status'].includes(key);
|
||||
|
||||
/**
|
||||
* Take off every row a Contacts snapshot built, as when Contacts goes off:
|
||||
* its contacts, distances and CURRENT would otherwise read as live. Rows the
|
||||
* session made without Contacts, such as TRACK ACQUIRED, stay.
|
||||
*/
|
||||
export function dropContextSnapshotSignals() {
|
||||
for (const key of [...this.signalSignatures.keys()])
|
||||
if (isContextSnapshotRow(key)) this.signalSignatures.delete(key);
|
||||
const kept = this.signalItems.filter(
|
||||
(item) => !isContextSnapshotRow(item.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 +155,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 +167,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(),
|
||||
});
|
||||
|
||||
@@ -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 || '')
|
||||
@@ -131,6 +155,7 @@ export function _adoptTrackedEntity(nowMs, suppliedInfo = null) {
|
||||
this.lastContextUpdateMs = 0;
|
||||
this.lastCameraUpdateMs = 0;
|
||||
this.contextNavigationDeadlineMs = 0;
|
||||
this.onRide?.(info.layerId);
|
||||
this.dispatchCockpitModeChanged(true, info);
|
||||
return true;
|
||||
}
|
||||
@@ -206,6 +231,7 @@ export function enter() {
|
||||
this.mapViewButton?.focus({ preventScroll: true });
|
||||
});
|
||||
this.onEntered?.();
|
||||
this.onRide?.(info.layerId);
|
||||
this.dispatchCockpitModeChanged(true, info);
|
||||
return true;
|
||||
}
|
||||
@@ -247,9 +273,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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user