diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 28ebb17d2..829d0713c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -41,7 +41,6 @@ "src/ui/views/panels/CockpitRoute.vue", "src/ui/views/panels/CockpitRadar.vue", "src/ui/views/panels/CockpitOrbital.vue", - "src/ui/views/panels/CockpitOrbitalReach.vue", "src/ui/ownerFlag.js", "src/ui/cockpitPresentation.js", "src/data/headingDegrees.js", @@ -905,10 +904,12 @@ "src/ui/cockpitCamera.js", "src/ui/cockpitOrbitalView.js", "src/ui/cockpitOrbitalFocus.js", - "src/data/contextStore.js", - "src/data/pickedContext.js", - "src/data/pickRegistry.js", - "src/data/scenePick.js", + "src/ui/cockpitOrbitalSignals.js", + "src/ui/cockpitOrbitalSatellites.js", + "src/cockpitOrbitalSatellites.js", + "src/layers/installations/classLabel.js", + "src/data/installationNames.js", + "src/i18n/marker.js", "src/data/placeBelow.js", "src/layers/satellites/sensors.js", "src/data/adminBoundaries.js", @@ -1062,9 +1063,10 @@ "src/ui/cockpitCamera.js", "src/ui/cockpitOrbitalView.js", "src/ui/cockpitOrbitalFocus.js", - "src/data/contextStore.js", - "src/data/pickedContext.js", - "src/data/pickRegistry.js", + "src/ui/cockpitOrbitalSignals.js", + "src/ui/cockpitOrbitalSatellites.js", + "src/cockpitOrbitalSatellites.js", + "src/layers/installations/classLabel.js", "src/data/placeBelow.js", "src/layers/satellites/sensors.js", "src/data/adminBoundaries.js", @@ -2907,6 +2909,9 @@ "src/ui/cockpitCamera.js", "src/ui/cockpitOrbitalView.js", "src/ui/cockpitOrbitalFocus.js", + "src/ui/cockpitOrbitalSignals.js", + "src/ui/cockpitOrbitalSatellites.js", + "src/cockpitOrbitalSatellites.js", "src/data/placeBelow.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", @@ -4252,6 +4257,7 @@ "src/cockpitLook.js", "src/cockpitOrbital.js", "src/cockpitOrbitalFocus.js", + "src/cockpitOrbitalSatellites.js", "src/geoDistance.js", "src/data/motionModel.js", "src/contactsDetectionPolicy.js", diff --git a/src/cockpitOrbitalFocus.js b/src/cockpitOrbitalFocus.js index db7f0989c..623507fc0 100644 --- a/src/cockpitOrbitalFocus.js +++ b/src/cockpitOrbitalFocus.js @@ -21,22 +21,15 @@ import { */ export const ORBITAL_FOCUS_EASE_TAU_MS = 250; -/** How often the list of mapped points in reach is rebuilt, ms. */ +/** How often the orbital Live Signals rows are rebuilt, ms. */ export const ORBITAL_REACH_REFRESH_MS = 1000; -/** The most mapped points the reach list shows, nearest to nadir first. */ -export const ORBITAL_REACH_LIST_MAX = 12; - -/** The most detail rows (OpenStreetMap tags or properties) shown per point. */ -export const ORBITAL_REACH_DETAIL_MAX = 16; +/** The most mapped sites offered for the rows, nearest to nadir first. */ +export const ORBITAL_REACH_LIST_MAX = 6; /** Slack on the reach limit so a target held exactly at 45° stays. */ const REACH_EPSILON_DEG = 1e-6; -const WGS84_A = 6378137; -const WGS84_E2 = 6.69437999014e-3; - -const toRadians = (deg) => (deg * Math.PI) / 180; const toDegrees = (rad) => (rad * 180) / Math.PI; function dot(a, b) { @@ -52,29 +45,6 @@ function finiteVector(v) { ); } -/** - * Earth-fixed position of a geodetic point on the WGS84 ellipsoid, as - * Cesium's `Cartesian3.fromDegrees` places it. - * @param {number} latDeg - * @param {number} lonDeg - * @param {number} [heightM] Height above the ellipsoid. - * @returns {{x: number, y: number, z: number}|null} - */ -export function geodeticToEcef(latDeg, lonDeg, heightM = 0) { - if (!Number.isFinite(latDeg) || !Number.isFinite(lonDeg)) return null; - const h = Number.isFinite(heightM) ? heightM : 0; - const lat = toRadians(latDeg); - const lon = toRadians(lonDeg); - const sinLat = Math.sin(lat); - const cosLat = Math.cos(lat); - const n = WGS84_A / Math.sqrt(1 - WGS84_E2 * sinLat * sinLat); - return { - x: (n + h) * cosLat * Math.cos(lon), - y: (n + h) * cosLat * Math.sin(lon), - z: (n * (1 - WGS84_E2) + h) * sinLat, - }; -} - /** * The look that centres `target` in the picture, in the nadir frame's tilt * (north first, then east; see `offNadirOrientation`): east is the angle off @@ -246,36 +216,8 @@ export function pointsInReach( a.offNadirDeg - b.offNadirDeg || String(a.key).localeCompare(b.key), ); const limit = - Number.isInteger(max) && max >= 0 ? max : ORBITAL_REACH_LIST_MAX; + max === Infinity || (Number.isInteger(max) && max >= 0) + ? max + : ORBITAL_REACH_LIST_MAX; return { count: entries.length, entries: entries.slice(0, limit) }; } - -/** - * A mapped point's details as key/value rows: its OpenStreetMap tags, or the - * properties its layer keeps. Text, numbers and lists of text only; keys - * starting with `_` are internal. At most `max` rows, in the layer's order. - * @param {object|null|undefined} properties - * @param {number} [max] - * @returns {Array<{key: string, value: string}>} - */ -export function detailRows(properties, max = ORBITAL_REACH_DETAIL_MAX) { - const rows = []; - if (!properties || typeof properties !== 'object') return rows; - for (const [key, raw] of Object.entries(properties)) { - if (rows.length >= max) break; - if (!key || key.startsWith('_')) continue; - let value = null; - if (typeof raw === 'string') value = raw.trim(); - else if (typeof raw === 'number' && Number.isFinite(raw)) - value = String(raw); - else if (typeof raw === 'boolean') value = raw ? 'yes' : 'no'; - else if (Array.isArray(raw)) { - const items = raw - .filter((item) => typeof item === 'string' && item.trim()) - .map((item) => item.trim()); - value = items.join(', '); - } - if (value) rows.push({ key, value }); - } - return rows; -} diff --git a/src/cockpitOrbitalFocus.test.mjs b/src/cockpitOrbitalFocus.test.mjs index 67a0cdef8..62b11463b 100644 --- a/src/cockpitOrbitalFocus.test.mjs +++ b/src/cockpitOrbitalFocus.test.mjs @@ -7,12 +7,10 @@ import * as Cesium from 'cesium'; import { ORBITAL_FOCUS_EASE_TAU_MS, ORBITAL_REACH_LIST_MAX, - detailRows, easeFocusRemainder, focusEaseDtMs, focusInReach, focusLook, - geodeticToEcef, inFrame, pointsInReach, } from './cockpitOrbitalFocus.js'; @@ -53,25 +51,14 @@ const onSphere = (latDeg, lonDeg, radiusM) => { }; }; +/** A point on the WGS84 ellipsoid, as the globe draws it. */ +const geodeticToEcef = (latDeg, lonDeg, heightM = 0) => + Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, heightM); + const ISS_ALT_M = 420_000; const satelliteAt = (latDeg, lonDeg, altitudeM = ISS_ALT_M) => geodeticToEcef(latDeg, lonDeg, altitudeM); -test('geodetic positions match Cesium on the WGS84 ellipsoid', () => { - for (const [lat, lon, h] of [ - [0, 0, 0], - [52.37, 4.9, 12], - [-33.9, 151.2, 420_000], - [89.9, -120, 0], - ]) { - const mine = geodeticToEcef(lat, lon, h); - const cesium = Cesium.Cartesian3.fromDegrees(lon, lat, h); - for (const axis of ['x', 'y', 'z']) - near(mine[axis], cesium[axis], `${lat},${lon} ${axis}`, 1e-6); - } - assert.equal(geodeticToEcef(Number.NaN, 0), null); -}); - test('the focus look turns the nadir frame onto the target exactly', () => { const satellite = satelliteAt(30, -97); for (const heading of [null, 0, 51.6, 200]) { @@ -260,32 +247,3 @@ test('in frame follows the view, its height and its width', () => { assert.equal(inFrame(tilted, satellite, target, narrow), true); assert.equal(inFrame(tilted, satellite, target, { verticalFovRad: 0 }), false); }); - -test('details keep text, numbers and lists of text, and drop internal keys', () => { - assert.deepEqual( - detailRows({ - name: ' Camp Example ', - military: 'base', - height: 12, - bad: Number.NaN, - empty: '', - _internal: 'x', - memberNames: ['North field', '', 4, 'South field'], - validation: { ok: true }, - fixme: false, - }), - [ - { key: 'name', value: 'Camp Example' }, - { key: 'military', value: 'base' }, - { key: 'height', value: '12' }, - { key: 'memberNames', value: 'North field, South field' }, - { key: 'fixme', value: 'no' }, - ], - ); - assert.deepEqual(detailRows(null), []); - assert.equal( - detailRows(Object.fromEntries([...Array(40)].map((_, i) => [`k${i}`, 'v']))) - .length, - 16, - ); -}); diff --git a/src/cockpitOrbitalSatellites.js b/src/cockpitOrbitalSatellites.js new file mode 100644 index 000000000..21d12dd91 --- /dev/null +++ b/src/cockpitOrbitalSatellites.js @@ -0,0 +1,55 @@ +/** + * The camera's hop from the satellite the orbital view rides to another. + * No DOM, Cesium or i18n dependencies: vectors are plain `{x, y, z}` objects + * in Earth-fixed (ECEF) metres. + */ + +/** The most nearby satellites asked for the orbital rows, nearest first. */ +export const ORBITAL_SATELLITES_MAX = 8; + +/** How long the camera takes to hop to another satellite, ms. */ +export const ORBITAL_HOP_MS = 1200; + +function finiteVector(v) { + return ( + Boolean(v) && + Number.isFinite(v.x) && + Number.isFinite(v.y) && + Number.isFinite(v.z) + ); +} + +/** + * Where the camera is part-way through a hop: along the great circle + * between the two positions (never through the Earth), its distance from the + * centre interpolated linearly. Both ends may move during the hop; pass + * their positions now. + * @param {{x: number, y: number, z: number}} from + * @param {{x: number, y: number, z: number}} to + * @param {number} progress Eased, 0..1. + * @returns {{x: number, y: number, z: number}|null} + */ +export function hopPosition(from, to, progress) { + if (!finiteVector(from) || !finiteVector(to)) return null; + const p = Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 1)); + const ra = Math.hypot(from.x, from.y, from.z); + const rb = Math.hypot(to.x, to.y, to.z); + if (!(ra > 0) || !(rb > 0)) return null; + const a = { x: from.x / ra, y: from.y / ra, z: from.z / ra }; + const b = { x: to.x / rb, y: to.y / rb, z: to.z / rb }; + const cosine = Math.min(1, Math.max(-1, a.x * b.x + a.y * b.y + a.z * b.z)); + const omega = Math.acos(cosine); + let ka = 1 - p; + let kb = p; + if (omega > 1e-9) { + const sine = Math.sin(omega); + ka = Math.sin((1 - p) * omega) / sine; + kb = Math.sin(p * omega) / sine; + } + const r = ra + (rb - ra) * p; + return { + x: (a.x * ka + b.x * kb) * r, + y: (a.y * ka + b.y * kb) * r, + z: (a.z * ka + b.z * kb) * r, + }; +} diff --git a/src/cockpitOrbitalSatellites.test.mjs b/src/cockpitOrbitalSatellites.test.mjs new file mode 100644 index 000000000..57d84c3c7 --- /dev/null +++ b/src/cockpitOrbitalSatellites.test.mjs @@ -0,0 +1,28 @@ +// src/cockpitOrbitalSatellites.test.mjs — the camera's hop from one ridden +// satellite to another. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { hopPosition } from './cockpitOrbitalSatellites.js'; + +const at = (lat, lon, alt) => Cesium.Cartesian3.fromDegrees(lon, lat, alt); + +test('the hop follows the great circle and lands on the satellite', () => { + const from = at(0, 0, 400_000); + const to = at(0, 90, 800_000); + const start = hopPosition(from, to, 0); + for (const axis of ['x', 'y', 'z']) + assert.ok(Math.abs(start[axis] - from[axis]) < 1e-6, axis); + const end = hopPosition(from, to, 1); + for (const axis of ['x', 'y', 'z']) + assert.ok(Math.abs(end[axis] - to[axis]) < 1e-6, axis); + // Half-way it is above the Earth, between the two heights, not on the + // chord through it. + const mid = hopPosition(from, to, 0.5); + const radius = Math.hypot(mid.x, mid.y, mid.z); + const ra = Cesium.Cartesian3.magnitude(from); + const rb = Cesium.Cartesian3.magnitude(to); + assert.ok(Math.abs(radius - (ra + rb) / 2) < 1e-6); + assert.ok(Math.abs(Math.atan2(mid.y, mid.x) - Math.PI / 4) < 1e-9); + assert.equal(hopPosition(null, to, 0.5), null); +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 4df6ddef0..880eed4e0 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -806,18 +806,18 @@ "{value} KM/S": "{value} كم/ث", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "انحراف عن النظير {angle}° · {offset}", + "SITES STANDBY": "المواقع في وضع الاستعداد", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "فعّل المنشآت المرسومة لإشارات المواقع", + "Release the camera from {name}": "تحرير الكاميرا من {name}", + "RETURN · {name}": "عودة · {name}", + "PREVIOUS SATELLITE": "القمر الصناعي السابق", + "Return to {name}": "العودة إلى {name}", + "SATELLITE": "قمر صناعي", + "Ride {name}": "الانتقال إلى {name}", + "Unnamed site": "موقع بلا اسم", "OFF-NADIR {angle}°": "انحراف عن النظير {angle}°", "FOCUS · {name}": "تركيز · {name}", - "IN REACH · {count}": "في المتناول · {count}", - "NEAREST {shown} OF {count}": "أقرب {shown} من {count}", - "Mapped points in reach": "النقاط المرسومة في المتناول", - "Hide the mapped points in reach": "إخفاء النقاط المرسومة في المتناول", - "Show the mapped points in reach": "إظهار النقاط المرسومة في المتناول", - "NOTHING MAPPED IN REACH": "لا شيء مرسوم في المتناول", "Keep the camera on {name}": "إبقاء الكاميرا على {name}", - "Beyond the off-nadir reach": "خارج مدى الانحراف عن النظير", - "OPENSTREETMAP TAGS": "وسوم OpenStreetMap", - "DETAILS": "التفاصيل", "ZOOM {zoom}×": "تكبير {zoom}×", "SWATH {value} KM": "عرض نطاق التصوير {value} كم", "SWATH ≈ {value} KM": "عرض نطاق التصوير ≈ {value} كم", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 1e9020465..09f9c98a7 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -193,18 +193,18 @@ "{value} KM/S": "{value} KM/S", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "NADIRABWEICHUNG {angle}° · {offset}", + "SITES STANDBY": "ORTE STANDBY", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "KARTIERTE ANLAGEN FÜR ORTS-PINGS AKTIVIEREN", + "Release the camera from {name}": "Kamera von {name} lösen", + "RETURN · {name}": "ZURÜCK · {name}", + "PREVIOUS SATELLITE": "VORHERIGER SATELLIT", + "Return to {name}": "Zurück zu {name}", + "SATELLITE": "SATELLIT", + "Ride {name}": "Mit {name} fliegen", + "Unnamed site": "Unbenannter Ort", "OFF-NADIR {angle}°": "NADIRABWEICHUNG {angle}°", "FOCUS · {name}": "FOKUS · {name}", - "IN REACH · {count}": "IN REICHWEITE · {count}", - "NEAREST {shown} OF {count}": "NÄCHSTE {shown} VON {count}", - "Mapped points in reach": "Kartierte Punkte in Reichweite", - "Hide the mapped points in reach": "Kartierte Punkte in Reichweite ausblenden", - "Show the mapped points in reach": "Kartierte Punkte in Reichweite anzeigen", - "NOTHING MAPPED IN REACH": "NICHTS KARTIERTES IN REICHWEITE", "Keep the camera on {name}": "Kamera auf {name} halten", - "Beyond the off-nadir reach": "Außerhalb der Reichweite der Nadirabweichung", - "OPENSTREETMAP TAGS": "OPENSTREETMAP-TAGS", - "DETAILS": "EINZELHEITEN", "ZOOM {zoom}×": "ZOOM {zoom}×", "SWATH {value} KM": "STREIFENBREITE {value} KM", "SWATH ≈ {value} KM": "STREIFENBREITE ≈ {value} KM", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e4e1834a9..fa59166fa 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -664,18 +664,18 @@ "{value} KM/S": "{value} KM/S", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "FUERA DEL NADIR {angle}° · {offset}", + "SITES STANDBY": "SITIOS EN ESPERA", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ACTIVAR INSTALACIONES CARTOGRAFIADAS PARA PINGS DE SITIOS", + "Release the camera from {name}": "Soltar la cámara de {name}", + "RETURN · {name}": "VOLVER · {name}", + "PREVIOUS SATELLITE": "SATÉLITE ANTERIOR", + "Return to {name}": "Volver a {name}", + "SATELLITE": "SATÉLITE", + "Ride {name}": "Viajar en {name}", + "Unnamed site": "Sitio sin nombre", "OFF-NADIR {angle}°": "FUERA DEL NADIR {angle}°", "FOCUS · {name}": "ENFOQUE · {name}", - "IN REACH · {count}": "AL ALCANCE · {count}", - "NEAREST {shown} OF {count}": "{shown} MÁS CERCANOS DE {count}", - "Mapped points in reach": "Puntos cartografiados al alcance", - "Hide the mapped points in reach": "Ocultar los puntos cartografiados al alcance", - "Show the mapped points in reach": "Mostrar los puntos cartografiados al alcance", - "NOTHING MAPPED IN REACH": "NADA CARTOGRAFIADO AL ALCANCE", "Keep the camera on {name}": "Mantener la cámara sobre {name}", - "Beyond the off-nadir reach": "Más allá del alcance fuera del nadir", - "OPENSTREETMAP TAGS": "ETIQUETAS DE OPENSTREETMAP", - "DETAILS": "DETALLES", "ZOOM {zoom}×": "ZOOM {zoom}×", "SWATH {value} KM": "BARRIDO {value} KM", "SWATH ≈ {value} KM": "BARRIDO ≈ {value} KM", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 782c5b724..e597c118f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -829,18 +829,18 @@ "{value} KM/S": "{value} KM/S", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "HORS NADIR {angle}° · {offset}", + "SITES STANDBY": "SITES EN VEILLE", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ACTIVER LES INSTALLATIONS CARTOGRAPHIÉES POUR LES PINGS DE SITES", + "Release the camera from {name}": "Libérer la caméra de {name}", + "RETURN · {name}": "RETOUR · {name}", + "PREVIOUS SATELLITE": "SATELLITE PRÉCÉDENT", + "Return to {name}": "Revenir à {name}", + "SATELLITE": "SATELLITE", + "Ride {name}": "Suivre {name}", + "Unnamed site": "Site sans nom", "OFF-NADIR {angle}°": "HORS NADIR {angle}°", "FOCUS · {name}": "CENTRÉ · {name}", - "IN REACH · {count}": "À PORTÉE · {count}", - "NEAREST {shown} OF {count}": "LES {shown} PLUS PROCHES SUR {count}", - "Mapped points in reach": "Points cartographiés à portée", - "Hide the mapped points in reach": "Masquer les points cartographiés à portée", - "Show the mapped points in reach": "Afficher les points cartographiés à portée", - "NOTHING MAPPED IN REACH": "RIEN DE CARTOGRAPHIÉ À PORTÉE", "Keep the camera on {name}": "Garder la caméra sur {name}", - "Beyond the off-nadir reach": "Au-delà de la portée hors nadir", - "OPENSTREETMAP TAGS": "BALISES OPENSTREETMAP", - "DETAILS": "DÉTAILS", "ZOOM {zoom}×": "ZOOM {zoom}×", "SWATH {value} KM": "FAUCHÉE {value} KM", "SWATH ≈ {value} KM": "FAUCHÉE ≈ {value} KM", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 9553c8b92..687ac971c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -829,18 +829,18 @@ "{value} KM/S": "{value} KM/S", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "FUORI NADIR {angle}° · {offset}", + "SITES STANDBY": "SITI IN ATTESA", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ATTIVA LE INSTALLAZIONI MAPPATE PER I PING DEI SITI", + "Release the camera from {name}": "Sblocca la camera da {name}", + "RETURN · {name}": "TORNA · {name}", + "PREVIOUS SATELLITE": "SATELLITE PRECEDENTE", + "Return to {name}": "Torna a {name}", + "SATELLITE": "SATELLITE", + "Ride {name}": "Segui {name}", + "Unnamed site": "Sito senza nome", "OFF-NADIR {angle}°": "FUORI NADIR {angle}°", "FOCUS · {name}": "PUNTAMENTO · {name}", - "IN REACH · {count}": "A PORTATA · {count}", - "NEAREST {shown} OF {count}": "I {shown} PIÙ VICINI SU {count}", - "Mapped points in reach": "Punti mappati a portata", - "Hide the mapped points in reach": "Nascondi i punti mappati a portata", - "Show the mapped points in reach": "Mostra i punti mappati a portata", - "NOTHING MAPPED IN REACH": "NIENTE DI MAPPATO A PORTATA", "Keep the camera on {name}": "Mantieni la camera su {name}", - "Beyond the off-nadir reach": "Oltre la portata fuori nadir", - "OPENSTREETMAP TAGS": "TAG OPENSTREETMAP", - "DETAILS": "DETTAGLI", "ZOOM {zoom}×": "ZOOM {zoom}×", "SWATH {value} KM": "STRISCIATA {value} KM", "SWATH ≈ {value} KM": "STRISCIATA ≈ {value} KM", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 1ba00a474..f3f86ea2d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -803,18 +803,18 @@ "{value} KM/S": "{value} km/s", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "オフナディア {angle}° · {offset}", + "SITES STANDBY": "地点待機", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "マップ済み施設を有効にすると地点ピングを表示", + "Release the camera from {name}": "{name} からカメラを解放", + "RETURN · {name}": "戻る · {name}", + "PREVIOUS SATELLITE": "前の衛星", + "Return to {name}": "{name} に戻る", + "SATELLITE": "衛星", + "Ride {name}": "{name} に乗る", + "Unnamed site": "名称のない地点", "OFF-NADIR {angle}°": "オフナディア {angle}°", "FOCUS · {name}": "フォーカス · {name}", - "IN REACH · {count}": "到達範囲内 · {count}", - "NEAREST {shown} OF {count}": "{count} 件中 近い {shown} 件", - "Mapped points in reach": "到達範囲内の地図上の地点", - "Hide the mapped points in reach": "到達範囲内の地図上の地点を隠す", - "Show the mapped points in reach": "到達範囲内の地図上の地点を表示", - "NOTHING MAPPED IN REACH": "到達範囲内に地図上の地点なし", "Keep the camera on {name}": "カメラを {name} に向け続ける", - "Beyond the off-nadir reach": "オフナディアの到達範囲外", - "OPENSTREETMAP TAGS": "OpenStreetMap タグ", - "DETAILS": "詳細", "ZOOM {zoom}×": "ズーム {zoom}×", "SWATH {value} KM": "観測幅 {value} km", "SWATH ≈ {value} KM": "観測幅 ≈ {value} km", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 189404f79..054bfc32e 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -827,18 +827,18 @@ "{value} KM/S": "{value} KM/S", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "FORA DO NADIR {angle}° · {offset}", + "SITES STANDBY": "LOCAIS EM ESPERA", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ATIVE AS INSTALAÇÕES MAPEADAS PARA PINGS DE LOCAIS", + "Release the camera from {name}": "Soltar a câmera de {name}", + "RETURN · {name}": "VOLTAR · {name}", + "PREVIOUS SATELLITE": "SATÉLITE ANTERIOR", + "Return to {name}": "Voltar para {name}", + "SATELLITE": "SATÉLITE", + "Ride {name}": "Acompanhar {name}", + "Unnamed site": "Local sem nome", "OFF-NADIR {angle}°": "FORA DO NADIR {angle}°", "FOCUS · {name}": "FOCO · {name}", - "IN REACH · {count}": "AO ALCANCE · {count}", - "NEAREST {shown} OF {count}": "{shown} MAIS PRÓXIMOS DE {count}", - "Mapped points in reach": "Pontos mapeados ao alcance", - "Hide the mapped points in reach": "Ocultar os pontos mapeados ao alcance", - "Show the mapped points in reach": "Mostrar os pontos mapeados ao alcance", - "NOTHING MAPPED IN REACH": "NADA MAPEADO AO ALCANCE", "Keep the camera on {name}": "Manter a câmera em {name}", - "Beyond the off-nadir reach": "Além do alcance fora do nadir", - "OPENSTREETMAP TAGS": "ETIQUETAS DO OPENSTREETMAP", - "DETAILS": "DETALHES", "ZOOM {zoom}×": "ZOOM {zoom}×", "SWATH {value} KM": "FAIXA IMAGEADA {value} KM", "SWATH ≈ {value} KM": "FAIXA IMAGEADA ≈ {value} KM", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6eb44c31f..993edb912 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -189,18 +189,18 @@ "{value} KM/S": "{value} КМ/С", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}° · {offset}", + "SITES STANDBY": "ОБЪЕКТЫ В ОЖИДАНИИ", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ВКЛЮЧИТЕ ОТМЕЧЕННЫЕ ОБЪЕКТЫ ДЛЯ ПИНГОВ МЕСТ", + "Release the camera from {name}": "Освободить камеру от {name}", + "RETURN · {name}": "НАЗАД · {name}", + "PREVIOUS SATELLITE": "ПРЕДЫДУЩИЙ СПУТНИК", + "Return to {name}": "Вернуться к {name}", + "SATELLITE": "СПУТНИК", + "Ride {name}": "Перейти на {name}", + "Unnamed site": "Безымянный объект", "OFF-NADIR {angle}°": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}°", "FOCUS · {name}": "ФОКУС · {name}", - "IN REACH · {count}": "В ЗОНЕ ДОСЯГАЕМОСТИ · {count}", - "NEAREST {shown} OF {count}": "БЛИЖАЙШИЕ {shown} ИЗ {count}", - "Mapped points in reach": "Отмеченные на карте точки в зоне досягаемости", - "Hide the mapped points in reach": "Скрыть отмеченные точки в зоне досягаемости", - "Show the mapped points in reach": "Показать отмеченные точки в зоне досягаемости", - "NOTHING MAPPED IN REACH": "НЕТ ОТМЕЧЕННЫХ ТОЧЕК В ЗОНЕ ДОСЯГАЕМОСТИ", "Keep the camera on {name}": "Удерживать камеру на {name}", - "Beyond the off-nadir reach": "За пределами допустимого отклонения от надира", - "OPENSTREETMAP TAGS": "ТЕГИ OPENSTREETMAP", - "DETAILS": "ПОДРОБНОСТИ", "ZOOM {zoom}×": "ПРИБЛИЖЕНИЕ {zoom}×", "SWATH {value} KM": "ПОЛОСА ОБЗОРА {value} КМ", "SWATH ≈ {value} KM": "ПОЛОСА ОБЗОРА ≈ {value} КМ", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 328f0fe72..1fe699b69 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -841,18 +841,18 @@ "{value} KM/S": "{value} КМ/С", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}° · {offset}", + "SITES STANDBY": "ОБ'ЄКТИ: ОЧІКУВАННЯ", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "УВІМКНІТЬ ПОЗНАЧЕНІ ОБ'ЄКТИ, ЩОБ БАЧИТИ МІСЦЯ", + "Release the camera from {name}": "Звільнити камеру від {name}", + "RETURN · {name}": "НАЗАД · {name}", + "PREVIOUS SATELLITE": "ПОПЕРЕДНІЙ СУПУТНИК", + "Return to {name}": "Повернутися до {name}", + "SATELLITE": "СУПУТНИК", + "Ride {name}": "Перейти на {name}", + "Unnamed site": "Безіменний об'єкт", "OFF-NADIR {angle}°": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}°", "FOCUS · {name}": "ФОКУС · {name}", - "IN REACH · {count}": "У ЗОНІ ДОСЯЖНОСТІ · {count}", - "NEAREST {shown} OF {count}": "НАЙБЛИЖЧІ {shown} З {count}", - "Mapped points in reach": "Позначені на мапі точки в зоні досяжності", - "Hide the mapped points in reach": "Сховати позначені точки в зоні досяжності", - "Show the mapped points in reach": "Показати позначені точки в зоні досяжності", - "NOTHING MAPPED IN REACH": "НЕМАЄ ПОЗНАЧЕНИХ ТОЧОК У ЗОНІ ДОСЯЖНОСТІ", "Keep the camera on {name}": "Утримувати камеру на {name}", - "Beyond the off-nadir reach": "Поза межами допустимого відхилення від надира", - "OPENSTREETMAP TAGS": "ТЕГИ OPENSTREETMAP", - "DETAILS": "ПОДРОБИЦІ", "ZOOM {zoom}×": "НАБЛИЖЕННЯ {zoom}×", "SWATH {value} KM": "СМУГА ОГЛЯДУ {value} КМ", "SWATH ≈ {value} KM": "СМУГА ОГЛЯДУ ≈ {value} КМ", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 4d1b7b55c..537325aef 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -780,18 +780,18 @@ "{value} KM/S": "{value} km/s", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "偏离天底 {angle}° · {offset}", + "SITES STANDBY": "地点待机", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "启用已测绘设施以接收地点提醒", + "Release the camera from {name}": "让镜头离开{name}", + "RETURN · {name}": "返回 · {name}", + "PREVIOUS SATELLITE": "上一颗卫星", + "Return to {name}": "返回{name}", + "SATELLITE": "卫星", + "Ride {name}": "乘坐{name}", + "Unnamed site": "未命名地点", "OFF-NADIR {angle}°": "偏离天底 {angle}°", "FOCUS · {name}": "聚焦 · {name}", - "IN REACH · {count}": "可达范围内 · {count}", - "NEAREST {shown} OF {count}": "{count} 个中最近的 {shown} 个", - "Mapped points in reach": "可达范围内的地图点位", - "Hide the mapped points in reach": "隐藏可达范围内的地图点位", - "Show the mapped points in reach": "显示可达范围内的地图点位", - "NOTHING MAPPED IN REACH": "可达范围内没有地图点位", "Keep the camera on {name}": "让镜头持续对准{name}", - "Beyond the off-nadir reach": "超出偏离天底的可达范围", - "OPENSTREETMAP TAGS": "OpenStreetMap 标签", - "DETAILS": "详情", "ZOOM {zoom}×": "变焦 {zoom}×", "SWATH {value} KM": "幅宽 {value} km", "SWATH ≈ {value} KM": "幅宽 ≈ {value} km", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index cbead8578..f358a5128 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -775,18 +775,18 @@ "{value} KM/S": "{value} km/s", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "偏離天底 {angle}° · {offset}", + "SITES STANDBY": "地點待機", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "啟用已測繪設施以接收地點提醒", + "Release the camera from {name}": "讓鏡頭離開{name}", + "RETURN · {name}": "返回 · {name}", + "PREVIOUS SATELLITE": "上一顆衛星", + "Return to {name}": "返回{name}", + "SATELLITE": "衛星", + "Ride {name}": "乘坐{name}", + "Unnamed site": "未命名地點", "OFF-NADIR {angle}°": "偏離天底 {angle}°", "FOCUS · {name}": "聚焦 · {name}", - "IN REACH · {count}": "可達範圍內 · {count}", - "NEAREST {shown} OF {count}": "{count} 個中最近的 {shown} 個", - "Mapped points in reach": "可達範圍內的地圖點位", - "Hide the mapped points in reach": "隱藏可達範圍內的地圖點位", - "Show the mapped points in reach": "顯示可達範圍內的地圖點位", - "NOTHING MAPPED IN REACH": "可達範圍內沒有地圖點位", "Keep the camera on {name}": "讓鏡頭持續對準{name}", - "Beyond the off-nadir reach": "超出偏離天底的可達範圍", - "OPENSTREETMAP TAGS": "OpenStreetMap 標籤", - "DETAILS": "詳情", "ZOOM {zoom}×": "變焦 {zoom}×", "SWATH {value} KM": "幅寬 {value} km", "SWATH ≈ {value} KM": "幅寬 ≈ {value} km", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index e64b2307b..8c2a2bd75 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -585,7 +585,8 @@ "ΔN", "Ⅱ PAUSE", "≥ {value} MW", - "▲ {frp} MW" + "▲ {frp} MW", + "SATELLITE" ], "it": [ "1 min/s", @@ -776,7 +777,8 @@ "CC BY 4.0", "acartia.io ↗", "iNaturalist ↗", - "iNaturalist" + "iNaturalist", + "SATELLITE" ], "ja": [ "1090 MHz", diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 9952206a5..a11228dfa 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -363,6 +363,8 @@ export class StyleManager extends ShellFacade { militaryFlightsLayer: services.militaryFlightsLayer, aisLiveVesselsLayer: services.aisLiveVesselsLayer, satellitesLayer: services.satellitesLayer, + // Orbit's Live Signals rows list and focus its mapped sites. + militaryInstallationsLayer: services.militaryInstallationsLayer, isTr3b: services.isTr3b, toggleTr3b: services.toggleTr3b, militaryAwarenessLayer: services.militaryAwarenessLayer, @@ -413,8 +415,6 @@ export class StyleManager extends ShellFacade { useCockpitStore(applicationPinia()).publishRadar(view), orbital: (view) => useCockpitStore(applicationPinia()).publishOrbital(view), - orbitalReach: (view) => - useCockpitStore(applicationPinia()).publishOrbitalReach(view), utilityLayout: (layout) => useCockpitStore(applicationPinia()).publishUtilityLayout(layout), }, diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index ed66e8199..81b39fabe 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -33,6 +33,12 @@ import { routeSceneClick, toggleOrbitalFocus, } from './cockpitOrbitalFocus.js'; +import { + handleOrbitalSignalClick, + refreshOrbitalSignals, + updateOrbitalSignals, +} from './cockpitOrbitalSignals.js'; +import { rideOrbitalSatellite } from './cockpitOrbitalSatellites.js'; import { publishInstrumentsView, publishRouteView, @@ -233,7 +239,6 @@ export class CockpitViewController { this.publishUtilityLayout = publish.utilityLayout ?? (() => {}); this.publishRadar = publish.radar ?? (() => {}); this.publishOrbital = publish.orbital ?? (() => {}); - this.publishOrbitalReach = publish.orbitalReach ?? (() => {}); /** The bridge radar's range in nautical miles; kept across bridges. */ this.radarRangeNm = DEFAULT_RADAR_RANGE_NM; this.presence = { @@ -359,10 +364,6 @@ export class CockpitViewController { else if (target?.closest?.('#cockpit-orbital-nadir')) this.orbitalNadir(); else if (target?.closest?.('#cockpit-orbital-sensor')) this.jumpToSensorSwath(); - else { - const focus = target?.closest?.('[data-orbital-focus]'); - if (focus) this.toggleOrbitalFocus(focus.dataset.orbitalFocus); - } }); this._listen(this.signalToggle, 'click', () => this.setSignalCollapsed(!this.signalCollapsed, { user: true }), @@ -456,14 +457,37 @@ export class CockpitViewController { return onOrbitalDoubleClick.call(this, position); } /** - * FOCUS on the reach list: focus that point, switch to it, or end the - * focus held on it. - * @param {string} key The point's context id. + * A site's orbital Live Signals row: focus that site, switch to it, or end + * the focus held on it. + * @param {string} key The site's id in the Mapped Installations layer. * @returns {boolean} True when the focus changed. */ toggleOrbitalFocus(key) { return toggleOrbitalFocus.call(this, key); } + /** Rebuild the orbital Live Signals rows now. */ + refreshOrbitalSignals() { + return refreshOrbitalSignals.call(this); + } + /** Rebuild the orbital Live Signals rows on their clock. */ + updateOrbitalSignals(nowMs) { + return updateOrbitalSignals.call(this, nowMs); + } + /** + * Ride the satellite an orbital Live Signals row names (RETURN goes back). + * @param {number|string} noradId + * @returns {boolean} True when the ridden satellite changed. + */ + rideOrbitalSatellite(noradId) { + return rideOrbitalSatellite.call(this, noradId); + } + /** + * A click on an orbital Live Signals row. + * @returns {boolean} True in orbit. + */ + handleOrbitalSignalClick(layerId, id) { + return handleOrbitalSignalClick.call(this, layerId, id); + } /** * Tilt the orbital view one arrow step off nadir. * @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index 60a3259f9..ec85d5888 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -44,6 +44,7 @@ export class CockpitCoordinator { militaryFlightsLayer, aisLiveVesselsLayer, satellitesLayer, + militaryInstallationsLayer, isTr3b, toggleTr3b, militaryAwarenessLayer, @@ -67,6 +68,7 @@ export class CockpitCoordinator { militaryFlightsLayer, aisLiveVesselsLayer, satellitesLayer, + militaryInstallationsLayer, isTr3b, toggleTr3b, militaryAwarenessLayer, diff --git a/src/ui/cockpitOrbitalFocus.js b/src/ui/cockpitOrbitalFocus.js index dc2cfa738..810fb1c03 100644 --- a/src/ui/cockpitOrbitalFocus.js +++ b/src/ui/cockpitOrbitalFocus.js @@ -1,37 +1,59 @@ /** - * Ground focus in the Cockpit's orbital view: a click on a mapped point, or - * FOCUS on the reach list, keeps the camera on that point as the satellite - * moves; clicking elsewhere, FOCUS again, NADIR or the target leaving the - * off-nadir reach eases the view back. Also builds the list of mapped points - * in reach. Bound to the Cockpit controller like the orbital view itself + * Ground focus in the Cockpit's orbital view: a site's Live Signals row, or + * a click on a mapped site's marker, keeps the camera on that site as the + * satellite moves; clicking elsewhere, the site's row again, NADIR or the + * site leaving the off-nadir reach eases the view back. Sites come + * from the Mapped Installations layer's own records and pick ids. Bound to + * the Cockpit controller like the orbital view itself * (src/ui/cockpitOrbitalView.js); its state lives on the orbital session, so * it ends with the session. */ import * as Cesium from 'cesium'; import { clampOrbitalLook, easeLook } from '../cockpitLook.js'; -import { offNadirOrientation } from '../cockpitOrbital.js'; import { - ORBITAL_REACH_LIST_MAX, - ORBITAL_REACH_REFRESH_MS, - detailRows, + ORBITAL_EARTH_RADIUS_M, + offNadirOrientation, +} from '../cockpitOrbital.js'; +import { easeFocusRemainder, focusEaseDtMs, focusInReach, focusLook, - geodeticToEcef, pointsInReach, } from '../cockpitOrbitalFocus.js'; -import { - getContextStore, - isContextRecordActive, -} from '../data/contextStore.js'; import { isPointerFree } from '../data/inputOwnership.js'; -import { pickedKindForLayer } from '../data/pickedContext.js'; const NADIR_LOOK = Object.freeze({ yawDeg: 0, pitchDeg: 0 }); -/** Only fixed points on the ground are focus targets: no aircraft, ships or satellites. */ -const GROUND_KINDS = new Set(['feature', 'fire']); +/** The Mapped Installations layer's id. */ +const SITES_LAYER_ID = 'military-installations'; + +/** The most sites asked of the layer around the sub-satellite point. */ +const SITE_QUERY_MAX = 500; + +/** How long a site the layer dropped stays listed, ms (it may come back). */ +const SITE_MEMORY_MS = 10_000; + +/** The farthest from the sub-satellite point a listed site may lie, m. */ +const SITE_QUERY_MAX_RANGE_M = 3_000_000; + +/** The greatest angle off nadir the reach allows: 45° on both axes. */ +const REACH_CORNER_RAD = Math.acos(Math.cos(Math.PI / 4) ** 2); + +/** + * How far over the ground the reach extends from the sub-satellite point, + * m: the central angle at the reach's corner off nadir (or the horizon, if + * nearer), with a little margin, capped. Sites farther out are never asked + * of the layer. + */ +function reachGroundRangeM(altitudeM) { + const R = ORBITAL_EARTH_RADIUS_M; + const h = Math.max(0, Number.isFinite(altitudeM) ? altitudeM : 0); + const sine = ((R + h) / R) * Math.sin(REACH_CORNER_RAD); + const central = + sine >= 1 ? Math.acos(R / (R + h)) : Math.asin(sine) - REACH_CORNER_RAD; + return Math.min(SITE_QUERY_MAX_RANGE_M, R * central * 1.05); +} /** Whether the user asked for reduced motion: focus turns then cut. */ function prefersReducedMotion() { @@ -49,80 +71,103 @@ function inOrbit(controller) { ); } -/** The layers' on/off state, as `isContextRecordActive` reads it. */ -function layerGate(controller) { +function sitesLayer(controller) { + return controller.services?.militaryInstallationsLayer ?? null; +} + +/** Whether the Mapped Installations layer is on (unknown reads as off). */ +function sitesLayerOn(controller) { const isLayerEnabled = controller.services?.isLayerEnabled; return typeof isLayerEnabled === 'function' - ? { isEnabled: (layerId) => isLayerEnabled(layerId) !== false } - : null; -} - -/** A context record that is a fixed ground point of a layer that is on. */ -function groundRecord(controller, record) { - if (!record || !isContextRecordActive(record, layerGate(controller))) - return null; - if (!GROUND_KINDS.has(pickedKindForLayer(record.layerId))) return null; - if (!Number.isFinite(record.latitude) || !Number.isFinite(record.longitude)) - return null; - return record; -} - -/** Where a mapped point is drawn: its rendered anchor, else on the ellipsoid. */ -function recordPosition(record) { - const anchor = record.entity?.__localBaseCartesian; - if ( - anchor && - Number.isFinite(anchor.x) && - Number.isFinite(anchor.y) && - Number.isFinite(anchor.z) - ) - return { x: anchor.x, y: anchor.y, z: anchor.z }; - return geodeticToEcef(record.latitude, record.longitude, 0); -} - -/** The context record a scene pick landed on, when it is a ground point. */ -function pickedGroundRecord(controller, picked) { - if (!picked) return null; - const store = getContextStore(); - for (const carrier of [picked.id, picked.primitive?.id, picked.primitive]) { - const contextId = - carrier && typeof carrier === 'object' ? carrier.__gevContextId : null; - if (!contextId) continue; - const record = groundRecord(controller, store.entities.get(contextId)); - if (record) return record; - } - return null; -} - -/** The focus look at a record from the last frame's satellite, or null. */ -function lookAt(state, record) { - const frame = state.focusFrame; - if (!frame) return null; - return focusLook(frame.nadir, frame.position, recordPosition(record)); + ? isLayerEnabled(SITES_LAYER_ID) !== false + : false; } /** - * Point the view at a mapped point and keep it there: from the view as it is - * now, so switching targets turns rather than jumps. A point beyond the - * reach is refused. - * @returns {boolean} True when the focus is on that point. + * A mapped site by id: as the layer has it loaded now, else, while the + * layer is on, as the focus or the Live Signals rows last saw it. The layer + * reloads its thinned view as the camera moves, so a site in view can leave + * its records for a moment; a site does not move, so its last position + * stands. Null with the layer off or the site never seen. */ -function startFocus(record) { +function siteById(controller, id) { + let live = null; + try { + live = sitesLayer(controller)?.getById?.(id) ?? null; + } catch { + live = null; + } + if (live) return live; + if (!sitesLayerOn(controller)) return null; + const state = controller.orbital; + const key = String(id); + if (state?.focus?.key === key && state.focus.site) return state.focus.site; + return state?.listedSites?.get(key)?.site ?? null; +} + +/** Where a site is drawn, ECEF, or null. */ +function sitePosition(site) { + const p = site?.position; + return p && + Number.isFinite(p.x) && + Number.isFinite(p.y) && + Number.isFinite(p.z) + ? { x: p.x, y: p.y, z: p.z } + : null; +} + +/** The mapped site a canvas position shows, through the layer's pick ids. */ +function siteAt(controller, position) { + let picked = null; + try { + picked = controller.viewer.scene?.pick?.(position) ?? null; + } catch { + picked = null; + } + if (!picked) return null; + try { + return sitesLayer(controller)?.findByPick?.(picked) ?? null; + } catch { + return null; + } +} + +/** The focus look at a site from the last frame's satellite, or null. */ +function lookAt(state, site) { + const frame = state.focusFrame; + if (!frame) return null; + return focusLook(frame.nadir, frame.position, sitePosition(site)); +} + +/** + * Point the view at a mapped site and keep it there: from the view as it is + * now, so switching targets turns rather than jumps. A site beyond the + * reach is refused. + * @returns {boolean} True when the focus is on that site. + */ +function startFocus(site) { const state = this.orbital; - const look = lookAt(state, record); + const look = lookAt(state, site); // Before the first frame there is nothing to measure against; the frame // that follows checks the reach. if (state.focusFrame && !focusInReach(look)) return false; state.focus = { - key: String(record.id), - label: String(record.label || record.layerName || record.id), + key: String(site.id), + label: String(site.name || site.id), + // Where the site is, kept while the layer reloads around it. + site: { + id: site.id, + name: site.name, + class: site.class, + position: sitePosition(site), + }, look, // The view minus the target's look, eased away; set on the next frame. remainder: null, }; state.focusReleasing = false; if (look) state.lookTarget = clampOrbitalLook(look); - publishOrbitalReach.call(this); + this.refreshOrbitalSignals?.(); return true; } @@ -135,7 +180,11 @@ function startFocus(record) { */ export function releaseOrbitalFocus({ handOver = false } = {}) { const state = this.orbital; - if (!state?.focus) return false; + if (!state?.focus) { + // An arrow during the ease back takes the arrows' own clock again. + if (handOver && state) state.focusReleasing = false; + return false; + } state.focus = null; if (handOver) { state.lookTarget = clampOrbitalLook(state.lookView); @@ -147,36 +196,22 @@ export function releaseOrbitalFocus({ handOver = false } = {}) { state.focusReleasing = false; } else state.focusReleasing = true; } - publishOrbitalReach.call(this); + this.refreshOrbitalSignals?.(); return true; } /** - * FOCUS on the reach list: focus that point, switch to it from another, or + * A site's Live Signals row: focus that site, switch to it from another, or * end the focus when it is the one held. - * @param {string} key The point's context id. + * @param {string} key The site's id in the Mapped Installations layer. * @returns {boolean} True when the focus changed. */ export function toggleOrbitalFocus(key) { if (!inOrbit(this) || key === null || key === undefined) return false; const state = this.orbital; if (state.focus?.key === String(key)) return releaseOrbitalFocus.call(this); - const record = groundRecord( - this, - getContextStore().entities.get(String(key)), - ); - return record ? startFocus.call(this, record) : false; -} - -/** The mapped ground point drawn at a canvas position, or null. */ -function groundRecordAt(controller, position) { - let picked = null; - try { - picked = controller.viewer.scene?.pick?.(position) ?? null; - } catch { - picked = null; - } - return pickedGroundRecord(controller, picked); + const site = siteById(this, String(key)); + return site ? startFocus.call(this, site) : false; } /** @@ -191,11 +226,10 @@ export function onOrbitalDoubleClick(position) { if (!inOrbit(this) || !isPointerFree()) return false; if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y)) return false; - const record = groundRecordAt(this, position); - if (!record) return false; - // A point beyond the reach is not focused, but still never tracked. - if (this.orbital.focus?.key !== String(record.id)) - startFocus.call(this, record); + const site = siteAt(this, position); + if (!site) return false; + // A site beyond the reach is not focused, but still never tracked. + if (this.orbital.focus?.key !== String(site.id)) startFocus.call(this, site); return true; } @@ -210,12 +244,12 @@ export function onOrbitalClick(position) { if (!inOrbit(this) || !isPointerFree()) return false; if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y)) return false; - const record = groundRecordAt(this, position); + const site = siteAt(this, position); const state = this.orbital; - if (record) { - if (state.focus?.key === String(record.id)) return true; - if (startFocus.call(this, record)) return true; - // A mapped point beyond the reach leaves the view as it is. + if (site) { + if (state.focus?.key === String(site.id)) return true; + if (startFocus.call(this, site)) return true; + // A mapped site beyond the reach leaves the view as it is. return false; } releaseOrbitalFocus.call(this); @@ -249,9 +283,9 @@ export function routeSceneClick() { /** * The orbital view's look for one frame, while a focus holds or is ending. * A held focus aims at its target: the live look plus whatever is left of - * the turn onto it. The focus ends, easing back, when its point is gone (its - * layer off, the record dropped) or beyond the reach. Also rebuilds the - * reach list on its own clock. + * the turn onto it. The focus ends, easing back, when its site is gone (its + * layer off, the site no longer loaded) or beyond the reach. Also rebuilds + * the orbital Live Signals rows on their own clock. * @param {object} state The orbital session. * @param {{direction: object, up: object, right: object}} nadir This * frame's nadir frame. @@ -264,16 +298,19 @@ export function updateOrbitalFocus(state, nadir, position, nowMs) { nadir, position: { x: position.x, y: position.y, z: position.z }, }; + // After a satellite switch the first frame on the new satellite (the hop + // landed, or none) is the one the rows can be measured from: rebuild now. + if (state.awaitingFrame && !state.hop) { + state.awaitingFrame = false; + state.signalsMs = -Infinity; + } const dtMs = nowMs - (state.lookLastMs || nowMs); let aimed = false; const focus = state.focus; if (focus) { - const record = groundRecord( - this, - getContextStore().entities.get(focus.key), - ); - const look = record - ? focusLook(nadir, state.focusFrame.position, recordPosition(record)) + const site = siteById(this, focus.key); + const look = site + ? focusLook(nadir, state.focusFrame.position, sitePosition(site)) : null; if (!focusInReach(look)) { releaseOrbitalFocus.call(this); @@ -296,8 +333,7 @@ export function updateOrbitalFocus(state, nadir, position, nowMs) { aimed = true; } } - if (nowMs - (state.reachMs ?? -Infinity) >= ORBITAL_REACH_REFRESH_MS) - publishOrbitalReach.call(this, nowMs); + this.updateOrbitalSignals?.(nowMs); return aimed; } @@ -345,69 +381,95 @@ export function orbitalFocusReadout() { } /** - * Rebuild and publish the list of mapped points in reach: ground points of - * the layers that are on, facing the satellite and within the off-nadir - * reach or in the frame now, nearest to nadir first, at most - * ORBITAL_REACH_LIST_MAX. - * @param {number} [nowMs] + * The mapped sites the orbital view can be turned to, for the Live Signals + * rows: the Mapped Installations layer's loaded sites around the + * sub-satellite point (`getNearby`), with those seen in the last + * SITE_MEMORY_MS and the focused one, facing the satellite and within the + * off-nadir reach, nearest to nadir first. Empty before the first frame or + * with the layer off. + * @param {{latitude: number, longitude: number, altitudeM: number}} info + * The ridden satellite. + * @returns {{count: number, entries: Array<{site: object, + * offNadirDeg: number, focused: boolean}>}} `count` also counts sites in + * the frame but beyond the reach. */ -export function publishOrbitalReach(nowMs = performance.now()) { +export function orbitalSitesInReach(info) { const state = this.orbital; - if (!inOrbit(this) || !state) return; - state.reachMs = nowMs; - const frame = state.focusFrame; - const records = new Map(); - const candidates = []; - if (frame) { - for (const entry of getContextStore().entities.values()) { - const record = groundRecord(this, entry); - if (!record) continue; - const position = recordPosition(record); - if (!position) continue; - const key = String(record.id); - records.set(key, record); - candidates.push({ key, position }); - } + const frame = state?.focusFrame; + const layer = sitesLayer(this); + // The layer keeps its records when it is turned off: nothing is listed + // then, and no row is left to focus from. + if (state && !sitesLayerOn(this)) state.listedSites = new Map(); + if (!frame || typeof layer?.getNearby !== 'function' || !sitesLayerOn(this)) + return { count: 0, entries: [] }; + if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude)) + return { count: 0, entries: [] }; + const rangeM = reachGroundRangeM(info.altitudeM); + let sites = []; + try { + sites = layer.getNearby( + Cesium.Cartesian3.fromDegrees(info.longitude, info.latitude, 0), + rangeM, + SITE_QUERY_MAX, + ); + } catch { + sites = []; } + const byId = new Map(); + const candidates = []; + for (const site of sites || []) { + const position = sitePosition(site); + if (!position || site.id === null || site.id === undefined) continue; + const key = String(site.id); + byId.set(key, site); + candidates.push({ key, position }); + } + // In orbit the layer reloads its thinned view every second or so, and a + // site in view can drop out of its records and come back. Sites do not + // move: one seen within SITE_MEMORY_MS stays a candidate, so the rows hold + // still and a row's site can be focused from what the rows show. + const nowMs = performance.now(); + const remembered = new Map(); + for (const [key, entry] of state.listedSites ?? []) { + if (nowMs - entry.seenMs > SITE_MEMORY_MS || byId.has(key)) continue; + remembered.set(key, entry); + byId.set(key, entry.site); + candidates.push({ key, position: entry.site.position }); + } + // The focused site stays a candidate while the layer reloads around it. + const held = state.focus; + if (held?.site?.position && !byId.has(held.key)) { + byId.set(held.key, held.site); + candidates.push({ key: held.key, position: held.site.position }); + } + for (const [key, site] of byId) + if (!remembered.has(key)) + remembered.set(key, { + site: { ...site, position: sitePosition(site) }, + seenMs: nowMs, + }); + state.listedSites = remembered; const frustum = this.viewer.camera?.frustum; - const reach = frame - ? pointsInReach(candidates, { - nadir: frame.nadir, - view: offNadirOrientation(frame.nadir, { - northDeg: state.lookView.pitchDeg, - eastDeg: state.lookView.yawDeg, - }), - satellite: frame.position, - verticalFovRad: state.verticalFov, - aspect: Number.isFinite(frustum?.aspectRatio) ? frustum.aspectRatio : 1, - max: ORBITAL_REACH_LIST_MAX, - }) - : { count: 0, entries: [] }; - const focusKey = state.focus?.key ?? null; - this.publishOrbitalReach({ - count: reach.count, - focusKey, - entries: reach.entries.map(({ key, offNadirDeg, reachable }) => { - const record = records.get(key); - const source = String(record.source || ''); - return { - key, - label: String(record.label || record.layerName || key), - layerName: String(record.layerName || ''), - source, - latitude: record.latitude, - longitude: record.longitude, - offNadirDeg, - reachable, - focused: key === focusKey, - osm: /openstreetmap|\bosm\b/i.test(source), - details: detailRows(record.properties), - }; + const reach = pointsInReach(candidates, { + nadir: frame.nadir, + view: offNadirOrientation(frame.nadir, { + northDeg: state.lookView.pitchDeg, + eastDeg: state.lookView.yawDeg, }), + satellite: frame.position, + verticalFovRad: state.verticalFov, + aspect: Number.isFinite(frustum?.aspectRatio) ? frustum.aspectRatio : 1, + max: Infinity, }); -} - -/** Clear the reach list as the orbital session ends. */ -export function endOrbitalFocus() { - this.publishOrbitalReach(null); + const focusKey = state.focus?.key ?? null; + return { + count: reach.count, + entries: reach.entries + .filter((entry) => entry.reachable) + .map(({ key, offNadirDeg }) => ({ + site: byId.get(key), + offNadirDeg, + focused: key === focusKey, + })), + }; } diff --git a/src/ui/cockpitOrbitalFocus.test.mjs b/src/ui/cockpitOrbitalFocus.test.mjs index 1e1a232dd..eba4d348e 100644 --- a/src/ui/cockpitOrbitalFocus.test.mjs +++ b/src/ui/cockpitOrbitalFocus.test.mjs @@ -1,8 +1,11 @@ -// src/ui/cockpitOrbitalFocus.test.mjs — keeping the orbital camera on a -// mapped ground point: a click on the point or FOCUS on the reach list turns -// the view onto it and holds it as the satellite moves; a click elsewhere, -// FOCUS again, NADIR or the point leaving the reach eases the view back, -// never in one jump. Over happy-dom, with the shared context store. +// src/ui/cockpitOrbitalFocus.test.mjs — the orbital view's Live Signals +// rows and its ground focus: the rows list the Mapped Installations layer's +// sites around the satellite (getNearby) and the nearest satellites; a site +// row, or a click on the site's marker (the layer's pick ids), turns the +// camera onto it and holds it as the satellite moves; a click elsewhere, the +// row again, NADIR or the site leaving the reach eases the view back, never +// in one jump; a satellite row rides that satellite and stays in orbit. +// The layers are stubs with the real methods' shapes. Over happy-dom. import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -15,52 +18,213 @@ import { useHudStore } from './stores/hud.js'; import CockpitView from './views/CockpitView.vue'; import ContextView from './views/ContextView.vue'; import IntelHudView from './views/IntelHudView.vue'; -import { getContextStore, registerEntityContext } from '../data/contextStore.js'; import { claimPointer, releasePointer } from '../data/inputOwnership.js'; import { ORBITAL_FOCUS_EASE_TAU_MS } from '../cockpitOrbitalFocus.js'; +import { ORBITAL_HOP_MS } from '../cockpitOrbitalSatellites.js'; const START_MS = 1_800_000_000_000; const ASPECT = 16 / 9; const FRAME_MS = 16; -const ISS = Object.freeze({ - kind: 'satellite', - layerId: 'satellites', - id: '25544', - noradId: 25544, - name: 'ISS (ZARYA)', - classKey: 'station', - classLabel: 'STATION · ISS', - latitude: 30, - longitude: -97, - altitudeM: 420_000, - velocityMps: 7_660, - // Track-up: the focus is built on the turned frame. - groundTrackDeg: 51.6, - stale: false, +/** Satellites the layer knows, by NORAD id: the ridden one and its neighbours. */ +const SATELLITES = Object.freeze({ + 25544: { name: 'ISS (ZARYA)', lat: 30, lon: -97, alt: 420_000, cls: 'stations' }, + 49260: { name: 'LANDSAT 9', lat: 31.5, lon: -95, alt: 705_000, cls: 'imaging' }, + 40697: { name: 'SENTINEL-2A', lat: 26, lon: -99, alt: 786_000, cls: 'imaging' }, + 99001: { name: 'DEB FRAGMENT', lat: 30.2, lon: -97.1, alt: 430_000, cls: 'debris' }, }); -const LAYER = 'military-installations'; - -/** Mapped points: two in reach, one only beyond it. */ +/** Mapped sites as the installations layer keeps them. */ const SITES = Object.freeze({ - alpha: { lat: 31, lon: -96.5, label: 'Camp Alpha', at: { x: 100, y: 100 } }, - bravo: { lat: 29.2, lon: -97.8, label: 'Bravo Field', at: { x: 300, y: 300 } }, - far: { lat: 33.5, lon: -101, label: 'Far Range', at: { x: 500, y: 50 } }, + alpha: { + id: 'osm:military:way/101', + osmKey: 'way/101', + name: 'Camp Alpha', + class: 'base', + lat: 31, + lon: -96.5, + memberNames: ['North Field'], + at: { x: 100, y: 100 }, + }, + bravo: { + id: 'osm:military:way/202', + osmKey: 'way/202', + name: 'Bravo Field', + class: 'airfield', + lat: 29.2, + lon: -97.8, + at: { x: 300, y: 300 }, + }, + // Beyond the ±45° reach of the ISS at its start. + far: { + id: 'osm:military:way/303', + osmKey: 'way/303', + name: 'Far Range', + class: 'range', + lat: 33.5, + lon: -101, + at: { x: 500, y: 50 }, + }, }); const EMPTY_GROUND = Object.freeze({ x: 640, y: 360 }); +/** The Mapped Installations layer: its records, getNearby, getById, findByPick. */ +function installationsLayer() { + const records = Object.values(SITES).map((site) => ({ + id: site.id, + osmKey: site.osmKey, + name: site.name, + class: site.class, + kind: 'installation', + latitude: site.lat, + longitude: site.lon, + memberNames: site.memberNames || [], + sources: [{ name: 'OpenStreetMap', id: site.osmKey }], + })); + const layer = { + enabled: true, + records, + calls: { getNearby: 0 }, + withPosition(record) { + return { + ...record, + position: Cesium.Cartesian3.fromDegrees(record.longitude, record.latitude, 0), + }; + }, + // As the layer's own: loaded installations within `rangeM` of + // `center`, nearest first, each with its drawn position and distance. + getNearby(center, rangeM, maxCount = 50) { + layer.calls.getNearby += 1; + if (!center) return []; + const from = Cesium.Cartographic.fromCartesian(center); + const geodesic = new Cesium.EllipsoidGeodesic(); + return layer.records + .filter((record) => record.kind === 'installation') + .map((record) => { + geodesic.setEndPoints( + from, + Cesium.Cartographic.fromDegrees(record.longitude, record.latitude), + ); + return { ...layer.withPosition(record), distanceM: geodesic.surfaceDistance }; + }) + .filter((record) => record.distanceM <= rangeM) + .sort((a, b) => a.distanceM - b.distanceM) + .slice(0, maxCount); + }, + getById(id) { + if (!layer.enabled) return null; + const record = layer.records.find((r) => r.id === String(id)); + return record ? layer.withPosition(record) : null; + }, + findByPick(picked) { + const id = + picked?.id?.installationId || + (typeof picked?.id?.id === 'string' ? picked.id.id : null); + return id ? layer.getById(id) : null; + }, + }; + return layer; +} + +/** The satellites layer: tracking, the Cockpit subject and every position. */ +function satellitesLayer(viewer) { + const positions = new Map( + Object.entries(SATELLITES).map(([id, sat]) => [Number(id), { ...sat }]), + ); + const layer = { + tracked: 25544, + calls: [], + position(id) { + const sat = positions.get(id); + return Cesium.Cartesian3.fromDegrees(sat.lon, sat.lat, sat.alt); + }, + move(dLat, dLon) { + const sat = positions.get(layer.tracked); + sat.lat += dLat; + sat.lon += dLon; + }, + getCockpitSubject() { + if (layer.tracked === null) return null; + const sat = positions.get(layer.tracked); + return { + kind: 'satellite', + layerId: 'satellites', + id: String(layer.tracked), + noradId: layer.tracked, + name: sat.name, + classLabel: sat.cls.toUpperCase(), + latitude: sat.lat, + longitude: sat.lon, + altitudeM: sat.alt, + velocityMps: 7_600, + groundTrackDeg: 51.6, + satelliteClass: sat.cls, + stale: false, + }; + }, + getCockpitPosition(result) { + if (layer.tracked === null) return null; + return Cesium.Cartesian3.clone(layer.position(layer.tracked), result); + }, + getAllPositions(maxCount = 300) { + return [...positions.entries()].slice(0, maxCount).map(([id, sat]) => ({ + id, + label: sat.name, + position: layer.position(id), + latitude: sat.lat, + longitude: sat.lon, + altitudeM: sat.alt, + satelliteClass: sat.cls, + })); + }, + // As the layer's: the nearest shown satellites, bounded, with the + // asked-for ids and classes left out. + getNearestPositions(center, maxCount = 8, { excludeIds = [], excludeClasses = [] } = {}) { + layer.calls.push(['nearest']); + const skip = new Set(excludeIds.map(String)); + return layer + .getAllPositions() + .filter((entry) => !skip.has(String(entry.id))) + .filter((entry) => !excludeClasses.includes(entry.satelliteClass)) + .map((entry) => ({ + ...entry, + distanceM: Cesium.Cartesian3.distance(center, entry.position), + })) + .sort((a, b) => a.distanceM - b.distanceM) + .slice(0, maxCount); + }, + /** The satellite leaves the catalogue. */ + remove(id) { + positions.delete(Number(id)); + }, + // As the layer's: tracking hands the camera to Cesium's follow. + trackById(noradId) { + const id = Number(noradId); + if (!positions.has(id)) return false; + layer.calls.push(['track', id]); + layer.tracked = id; + viewer.trackedEntity = { position: { getValue: () => layer.position(id) } }; + return true; + }, + setCockpitSubject(noradId) { + layer.calls.push(['subject', noradId]); + return true; + }, + refocusTrackedById: () => true, + drop() { + layer.tracked = null; + }, + }; + return layer; +} + function fixture(t, { reducedMotion = false } = {}) { document.body.replaceChildren(); const host = document.body.appendChild(document.createElement('div')); const pinia = createPinia(); const app = createApp( - defineComponent(() => () => [ - h(ContextView), - h(IntelHudView), - h(CockpitView), - ]), + defineComponent(() => () => [h(ContextView), h(IntelHudView), h(CockpitView)]), ).use(pinia); app.mount(host); useHudStore(pinia); @@ -95,64 +259,51 @@ function fixture(t, { reducedMotion = false } = {}) { trackedEntityChanged: listeners(), }; // The Viewer's own input handler: its single click picks into - // selectedEntity, which the Cockpit must still hand it. + // selectedEntity; its double-click tracks what it picks, which would end + // the orbital view. const actions = new Map(); const handler = { stockClicks: 0, + stockDoubleClicks: 0, getInputAction: (type) => actions.get(type), setInputAction: (action, type) => actions.set(type, action), removeInputAction: (type) => actions.delete(type), isDestroyed: () => false, }; - const stockClick = () => { + handler.stock = () => { handler.stockClicks += 1; }; - handler.stock = stockClick; - actions.set(Cesium.ScreenSpaceEventType.LEFT_CLICK, stockClick); - // Its double-click tracks what it picks (pickAndTrackObject): a mapped - // point, tracked, would end the orbital view. - handler.stockDoubleClicks = 0; - const stockDoubleClick = ({ position }) => { + handler.stockDouble = ({ position }) => { handler.stockDoubleClicks += 1; const picked = viewer.scene.pick(position); - viewer.trackedEntity = picked?.id - ? { ...picked.id, position: { getValue: () => null } } + viewer.trackedEntity = picked + ? { position: { getValue: () => null } } : undefined; }; - handler.stockDouble = stockDoubleClick; - actions.set(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, stockDoubleClick); + actions.set(Cesium.ScreenSpaceEventType.LEFT_CLICK, handler.stock); + actions.set(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, handler.stockDouble); viewer.screenSpaceEventHandler = handler; - let subject = { ...ISS }; - const satellites = { - getCockpitSubject: () => (subject ? { ...subject } : null), - getCockpitPosition(result) { - if (!subject) return null; - return Cesium.Cartesian3.fromDegrees( - subject.longitude, - subject.latitude, - subject.altitudeM, - Cesium.Ellipsoid.WGS84, - result, - ); - }, - setCockpitSubject: () => true, - refocusTrackedById: () => true, - }; - const enabled = new Set(['satellites', LAYER]); + const sites = installationsLayer(); + const satellites = satellitesLayer(viewer); + const modeEvents = []; + const onMode = (event) => modeEvents.push(event.detail); + window.addEventListener('gev:cockpit-mode-changed', onMode); const cockpit = new CockpitViewController(viewer, { services: { flightsLayer: { getTrackedInfo: () => null }, militaryFlightsLayer: { getTrackedInfo: () => null }, aisLiveVesselsLayer: { getCockpitSubject: () => null }, satellitesLayer: satellites, + militaryInstallationsLayer: sites, militaryAwarenessLayer: {}, + isLayerEnabled: (layerId) => + layerId === 'military-installations' ? sites.enabled : true, isTr3b: () => false, holdContinuousRender() {}, releaseContinuousRender() {}, cachedGroundFloor: () => null, GROUND_FLOOR_LIFT_M: 0, - isLayerEnabled: (layerId) => enabled.has(layerId), }, isEntryAllowed: () => false, isBridgeEntryAllowed: () => false, @@ -163,7 +314,7 @@ function fixture(t, { reducedMotion = false } = {}) { route: (view) => store.publishRoute(view), radar: (view) => store.publishRadar(view), orbital: (view) => store.publishOrbital(view), - orbitalReach: (view) => store.publishOrbitalReach(view), + signals: (rows) => store.publishSignals(rows), }, }); Object.assign(cockpit, { @@ -171,42 +322,18 @@ function fixture(t, { reducedMotion = false } = {}) { showBriefPage() {}, startBriefRotation() {}, stopBriefRotation() {}, - pushCockpitSignal() {}, scheduleContextLayout() {}, updateLocalPosition() {}, maybeRefreshRegionalBrief() {}, updateContext() {}, updateRoute() {}, }); - - // The mapped points, registered as the installations layer does. - const carriers = {}; - for (const [key, site] of Object.entries(SITES)) { - const carrier = { show: true }; - carriers[key] = carrier; - registerEntityContext(carrier, { - id: `site-${key}`, - layerId: LAYER, - layerName: 'Mapped Military Installations', - source: 'OpenStreetMap', - label: site.label, - latitude: site.lat, - longitude: site.lon, - properties: { military: 'base', name: site.label, _internal: 'x' }, + // Named markers pick as `{ installationId }`, as the layer draws them. + for (const site of Object.values(SITES)) + picks.set(`${site.at.x},${site.at.y}`, { + id: { installationId: site.id }, + primitive: {}, }); - picks.set(`${site.at.x},${site.at.y}`, { id: carrier }); - } - // A moving contact is never a focus target. - const aircraftCarrier = { show: true }; - registerEntityContext(aircraftCarrier, { - id: 'flight-abc', - layerId: 'flights', - label: 'ABC123', - latitude: 30.2, - longitude: -97.1, - }); - enabled.add('flights'); - picks.set('700,700', { id: aircraftCarrier }); const originalNow = performance.now; const originalDateNow = Date.now; @@ -215,12 +342,12 @@ function fixture(t, { reducedMotion = false } = {}) { Date.now = () => nowMs; t.after(() => { + window.removeEventListener('gev:cockpit-mode-changed', onMode); cockpit.stop(); app.unmount(); globalThis.matchMedia = previousMatchMedia; performance.now = originalNow; Date.now = originalDateNow; - getContextStore().entities.clear(); document.body.classList.remove('cockpit-mode', 'cockpit-bridge', 'cockpit-orbit'); }); @@ -229,6 +356,7 @@ function fixture(t, { reducedMotion = false } = {}) { cockpit.update(); return shots.at(-1); }; + const fire = (type, position) => actions.get(type)?.({ position }); return { $: (selector) => document.querySelector(selector), cockpit, @@ -236,27 +364,21 @@ function fixture(t, { reducedMotion = false } = {}) { store, shots, handler, - carriers, - enabled, + sites, + satellites, + modeEvents, frame, - click: (position) => - actions.get(Cesium.ScreenSpaceEventType.LEFT_CLICK)?.({ position }), + click: (position) => fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, position), /** A double-click as Cesium fires it: two clicks, then the double. */ doubleClick(position) { - actions.get(Cesium.ScreenSpaceEventType.LEFT_CLICK)?.({ position }); - actions.get(Cesium.ScreenSpaceEventType.LEFT_CLICK)?.({ position }); - actions.get(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK)?.({ position }); + fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, position); + fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, position); + fire(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, position); }, - /** Move the satellite along its track by `deg` of latitude and longitude. */ - move(dLat, dLon) { - subject = { - ...subject, - latitude: subject.latitude + dLat, - longitude: subject.longitude + dLon, - }; - }, - drop() { - subject = null; + /** Rebuild the Context card on the next frame, past its clock. */ + refreshCard() { + nowMs += 1000; + cockpit.update(); }, }; } @@ -271,6 +393,7 @@ async function inOrbit(t, options) { assert.equal(scene.cockpit.enter(), true); await settle(); scene.frame(); + scene.refreshCard(); await settle(); return scene; } @@ -316,7 +439,7 @@ function run(scene, frames, { drift = 0.0005 } = {}) { let largest = 0; let previous = scene.shots.at(-1); for (let i = 0; i < frames; i += 1) { - scene.move(drift, drift); + scene.satellites.move(drift, drift); const shot = scene.frame(); largest = Math.max(largest, angleDeg(previous.direction, shot.direction)); previous = shot; @@ -334,93 +457,125 @@ const LAND_FRAMES = Math.ceil((12 * ORBITAL_FOCUS_EASE_TAU_MS) / FRAME_MS); */ const MAX_TURN_PER_FRAME_DEG = 3; -test('a click on a mapped point turns the camera onto it and holds it there', async (t) => { +/** A Live Signals row's button, by layer and id. */ +const rowButton = (scene, layerId, id) => + scene.$( + `#cockpit-signal-list button[data-signal-layer="${layerId}"][data-signal-id="${id}"]`, + ); +const siteRow = (scene, key) => + rowButton(scene, 'military-installations', SITES[key].id); +const satelliteRow = (scene, id) => rowButton(scene, 'satellites', String(id)); +/** The rows as published: title, detail, tone and target. */ +const rows = (scene) => scene.store.signals ?? []; +/** Rebuild the rows and let the view render them. */ +async function refreshRows(scene) { + scene.refreshCard(); + await settle(); +} + +test('in orbit Live Signals lists the sites getNearby finds and the nearest satellites', async (t) => { const scene = await inOrbit(t); - const { $, cockpit, store, handler } = scene; - const before = scene.shots.at(-1); - assert.ok(missDeg(before, 'alpha') > 10, 'not centred at nadir'); + const { $, sites } = scene; + assert.ok(sites.calls.getNearby > 0, 'asked the installations layer'); + assert.equal($('#cockpit-context').hidden, true, 'no Contact card in orbit'); + const shown = rows(scene); + assert.ok(shown.length <= 5, 'five rows at most'); + assert.equal(shown[0].title, 'TRACK ACQUIRED', 'the track row stays first'); + // Two site rows, nearest to nadir first, then the satellites (debris out). + const sitesShown = shown.filter((row) => row.target?.layerId === 'military-installations'); + assert.deepEqual( + sitesShown.map((row) => row.target.id).sort(), + [SITES.alpha.id, SITES.bravo.id].sort(), + ); + for (const row of sitesShown) { + assert.match(row.detail, /^(BASE|AIRFIELD) · OFF-NADIR \d+\.\d°$/); + assert.match(row.className, /nearby actionable/); + } + const satellitesShown = shown.filter((row) => row.target?.layerId === 'satellites'); + assert.deepEqual(satellitesShown.map((row) => row.title), ['LANDSAT 9', 'SENTINEL-2A']); + assert.match(satellitesShown[0].detail, /^SATELLITE · (AHEAD|[LR] \d{3}°) · [\d,.]+\s+KM$/); + // Each row names its own action. + assert.equal(siteRow(scene, 'alpha').getAttribute('aria-label'), 'Keep the camera on Camp Alpha'); + assert.equal(satelliteRow(scene, 49260).getAttribute('aria-label'), 'Ride LANDSAT 9'); + // Rebuilt on the clock, rows keep their keys and times. + const before = shown.map((row) => [row.key, row.time]); + await refreshRows(scene); + assert.deepEqual(rows(scene).map((row) => [row.key, row.time]), before); +}); - scene.click(SITES.alpha.at); - assert.equal(cockpit.orbital.focus.key, 'site-alpha'); - assert.equal(handler.stockClicks, 1, 'Cesium still gets the click'); - - // The first frames turn part of the way: no jump onto the target. - const first = scene.frame(); - assert.ok(missDeg(first, 'alpha') > 5, 'still turning'); +test('a site row turns the camera onto it, holds it, and reads its off-nadir angle', async (t) => { + const scene = await inOrbit(t); + const { $, cockpit, store } = scene; + assert.ok(missDeg(scene.shots.at(-1), 'alpha') > 10, 'not centred at nadir'); + siteRow(scene, 'alpha').click(); + assert.equal(cockpit.orbital.focus.key, SITES.alpha.id); + // The first frames turn part of the way: no jump onto the site. + assert.ok(missDeg(scene.frame(), 'alpha') > 5, 'still turning'); const largest = run(scene, LAND_FRAMES); assert.ok(largest > 0.1 && largest < MAX_TURN_PER_FRAME_DEG, `turned ${largest}° in a frame`); - - // Landed, it holds the target dead centre while the satellite moves. for (let i = 0; i < 20; i += 1) { - scene.move(0.002, 0.002); + scene.satellites.move(0.002, 0.002); const shot = scene.frame(); assert.ok(missDeg(shot, 'alpha') < 1e-5, `frame ${i}: ${missDeg(shot, 'alpha')}°`); } - // Held still past a HUD beat, the readout matches the last frame. for (let i = 0; i < 8; i += 1) scene.frame(); - - // The readout gives the single off-nadir angle to it, as image metadata does. await settle(); const shot = scene.shots.at(-1); - const readout = $('#cockpit-orbital-look-readout').textContent; - assert.equal(readout, `OFF-NADIR ${siteOffNadir(shot, 'alpha').toFixed(1)}°`); + assert.equal( + $('#cockpit-orbital-look-readout').textContent, + `OFF-NADIR ${siteOffNadir(shot, 'alpha').toFixed(1)}°`, + ); assert.equal($('#cockpit-orbital-focus').textContent, 'FOCUS · Camp Alpha'); assert.equal($('#cockpit-orbital-look-readout').getAttribute('aria-live'), 'off'); - assert.equal( - $('.cockpit-orbital-kicker').textContent.trim(), - 'ORBITAL VIEW · OFF-NADIR', - ); - // The swath stays approximate, scaled by the tilt onto the target. assert.equal(store.orbital.footprintKm, null); - assert.ok(store.orbital.look.swathScale > 1); assert.match($('.cockpit-orbital-swath').textContent, /^SWATH ≈ [\d.]+ KM$/); - assert.deepEqual(store.orbital.look.focus, { key: 'site-alpha', label: 'Camp Alpha' }); - - // It keeps up with the satellite: the angle changes as it moves. - scene.move(-0.3, -0.3); - for (let i = 0; i < 8; i += 1) scene.frame(); - await settle(); - const later = $('#cockpit-orbital-look-readout').textContent; - assert.notEqual(later, readout); - assert.equal(later, `OFF-NADIR ${siteOffNadir(scene.shots.at(-1), 'alpha').toFixed(1)}°`); + // The held site's row reads as the subject does, first among the sites. + const held = rows(scene).find((row) => row.target?.id === SITES.alpha.id); + assert.match(held.className, /^track actionable$/); + assert.match(held.detail, /^FOCUS · BASE · OFF-NADIR/); + assert.equal(siteRow(scene, 'alpha').getAttribute('aria-label'), 'Release the camera from Camp Alpha'); }); -test('a click elsewhere eases the camera back to nadir', async (t) => { +test('another site row switches the focus; the held site row ends it', async (t) => { const scene = await inOrbit(t); - const { $, cockpit } = scene; - scene.click(SITES.alpha.at); + const { cockpit } = scene; + siteRow(scene, 'alpha').click(); run(scene, LAND_FRAMES); - const focused = offNadir(scene.shots.at(-1)); - assert.ok(focused > 10); + await settle(); + siteRow(scene, 'bravo').click(); + assert.equal(cockpit.orbital.focus.key, SITES.bravo.id); + const largest = run(scene, LAND_FRAMES); + assert.ok(largest < MAX_TURN_PER_FRAME_DEG, `turned ${largest}° in a frame`); + assert.ok(missDeg(scene.shots.at(-1), 'bravo') < 1e-5); + await settle(); + assert.match(rows(scene).find((row) => row.target?.id === SITES.alpha.id).className, /^nearby/); + siteRow(scene, 'bravo').click(); + assert.equal(cockpit.orbital.focus, null); + const back = run(scene, LAND_FRAMES, { drift: 0 }); + assert.ok(back < MAX_TURN_PER_FRAME_DEG); + assert.ok(offNadir(scene.shots.at(-1)) < 1e-6, 'back at nadir'); +}); +test('a click on a site marker focuses it through the layer; a click elsewhere eases back', async (t) => { + const scene = await inOrbit(t); + const { cockpit, handler } = scene; + scene.click(SITES.alpha.at); + assert.equal(cockpit.orbital.focus.key, SITES.alpha.id); + assert.equal(handler.stockClicks, 1, 'Cesium still gets the click'); + run(scene, LAND_FRAMES); + assert.ok(missDeg(scene.shots.at(-1), 'alpha') < 1e-5); + scene.click(SITES.alpha.at); + assert.equal(cockpit.orbital.focus.key, SITES.alpha.id, 'kept on a second click'); + const focused = offNadir(scene.shots.at(-1)); scene.click(EMPTY_GROUND); assert.equal(cockpit.orbital.focus, null); - const first = scene.frame(); - assert.ok(offNadir(first) > focused * 0.8, 'no jump back'); + assert.ok(offNadir(scene.frame()) > focused * 0.8, 'no jump back'); const largest = run(scene, LAND_FRAMES, { drift: 0 }); - assert.ok(largest < MAX_TURN_PER_FRAME_DEG, `turned ${largest}° in a frame`); - assert.ok(offNadir(scene.shots.at(-1)) < 1e-6, 'back at nadir'); - await settle(); - assert.equal($('.cockpit-orbital-look'), null); - assert.equal($('#cockpit-orbital-focus').textContent, ''); -}); - -test('a click on a moving contact is not a focus and ends one', async (t) => { - const scene = await inOrbit(t); - scene.click(SITES.alpha.at); - scene.frame(); - scene.click({ x: 700, y: 700 }); - assert.equal(scene.cockpit.orbital.focus, null); - scene.click({ x: 700, y: 700 }); - assert.equal(scene.cockpit.orbital.focus, null); -}); - -test('a click on the focused point again keeps the focus', async (t) => { - const scene = await inOrbit(t); - scene.click(SITES.alpha.at); - run(scene, 5); - scene.click(SITES.alpha.at); - assert.equal(scene.cockpit.orbital.focus.key, 'site-alpha'); + assert.ok(largest < MAX_TURN_PER_FRAME_DEG); + assert.ok(offNadir(scene.shots.at(-1)) < 1e-6); + // A site beyond the reach is not focused. + scene.click(SITES.far.at); + assert.equal(cockpit.orbital.focus ?? null, null); }); test('a click a tool owns is left alone', async (t) => { @@ -430,183 +585,236 @@ test('a click a tool owns is left alone', async (t) => { scene.click(SITES.alpha.at); assert.equal(scene.cockpit.orbital.focus ?? null, null, 'no focus while drawing'); releasePointer(lease); - scene.click(SITES.alpha.at); - assert.equal(scene.cockpit.orbital.focus.key, 'site-alpha'); const again = claimPointer('draw'); t.after(() => releasePointer(again)); scene.click(EMPTY_GROUND); - assert.equal(scene.cockpit.orbital.focus.key, 'site-alpha', 'kept while drawing'); + assert.equal(scene.cockpit.orbital.focus.key, SITES.alpha.id, 'kept while drawing'); }); -test('the focus ends by itself, easing back, when the point leaves the reach', async (t) => { +test('a double-click on a site focuses it and keeps the orbital view', async (t) => { + const scene = await inOrbit(t); + const { cockpit, viewer, handler } = scene; + scene.doubleClick(SITES.alpha.at); + assert.equal(handler.stockDoubleClicks, 0, 'Cesium does not track the site'); + assert.equal(viewer.trackedEntity, undefined); + scene.frame(); + assert.equal(cockpit.active, true, 'still in orbit'); + assert.equal(cockpit.orbital.focus.key, SITES.alpha.id); + scene.doubleClick(SITES.bravo.at); + assert.equal(cockpit.orbital.focus.key, SITES.bravo.id); + scene.doubleClick(SITES.far.at); + assert.equal(handler.stockDoubleClicks, 0, 'beyond reach: not focused, not tracked'); + scene.frame(); + assert.equal(cockpit.active, true); + scene.doubleClick(EMPTY_GROUND); + assert.equal(handler.stockDoubleClicks, 1, 'anywhere else goes on to Cesium'); +}); + +test('the focus ends by itself, easing back, when the site leaves the reach', async (t) => { const scene = await inOrbit(t); const { cockpit } = scene; - scene.click(SITES.bravo.at); + siteRow(scene, 'bravo').click(); run(scene, LAND_FRAMES, { drift: 0 }); - assert.ok(missDeg(scene.shots.at(-1), 'bravo') < 1e-5); - // The satellite runs on north-east, away from the point, until the tilt - // onto it would pass 45°. let released = null; let previous = scene.shots.at(-1); let largest = 0; for (let i = 0; i < 2000 && released === null; i += 1) { - scene.move(0.004, 0.004); + scene.satellites.move(0.004, 0.004); const shot = scene.frame(); largest = Math.max(largest, angleDeg(previous.direction, shot.direction)); previous = shot; if (!cockpit.orbital.focus) released = i; } assert.ok(released > 0, 'the focus ended'); - const atRelease = offNadir(scene.shots.at(-1)); - assert.ok(atRelease > 40, `held to the reach (${atRelease}°)`); + assert.ok(offNadir(scene.shots.at(-1)) > 40, 'held to the reach'); largest = Math.max(largest, run(scene, LAND_FRAMES, { drift: 0 })); assert.ok(largest < MAX_TURN_PER_FRAME_DEG, `turned ${largest}° in a frame`); assert.ok(offNadir(scene.shots.at(-1)) < 1e-6, 'back at nadir'); }); -test('a mapped point beyond the reach cannot be focused', async (t) => { - const scene = await inOrbit(t); - scene.click(SITES.far.at); - assert.equal(scene.cockpit.orbital.focus ?? null, null); - assert.equal(scene.cockpit.toggleOrbitalFocus('site-far'), false); -}); - -test('the reach list offers the points in reach with details and FOCUS', async (t) => { - const scene = await inOrbit(t); - const { $, store, cockpit } = scene; - await settle(); - const reach = store.orbitalReach; - assert.ok(reach, 'published in orbit'); - // Nearest to nadir first; moving contacts and points beyond reach and - // frame are left out. - const keys = reach.entries.map((entry) => entry.key); - assert.ok(keys.includes('site-alpha') && keys.includes('site-bravo')); - assert.ok(!keys.includes('flight-abc')); - assert.ok(!keys.includes('site-far')); - const angles = reach.entries.map((entry) => entry.offNadirDeg); - assert.deepEqual(angles, [...angles].sort((a, b) => a - b)); - assert.equal(reach.count, reach.entries.length); - - const toggle = $('#cockpit-orbital-reach-toggle'); - assert.equal(toggle.textContent, `IN REACH · ${reach.count}`); - assert.equal(toggle.getAttribute('aria-expanded'), 'false'); - assert.equal($('#cockpit-orbital-reach-body').style.display, 'none'); - toggle.click(); - await settle(); - assert.equal(toggle.getAttribute('aria-expanded'), 'true'); - assert.notEqual($('#cockpit-orbital-reach-body').style.display, 'none'); - - const row = (key) => $(`[data-orbital-reach-key="${key}"]`); - const name = row('site-alpha').querySelector('.cockpit-orbital-reach-name'); - assert.match(name.textContent, /Camp Alpha/); - name.click(); - await settle(); - assert.equal(name.getAttribute('aria-expanded'), 'true'); - const details = $('#cockpit-orbital-reach-details'); - assert.match(details.textContent, /OPENSTREETMAP TAGS/); - assert.match(details.textContent, /military\s*base/); - assert.doesNotMatch(details.textContent, /_internal/); - assert.equal(name.getAttribute('aria-controls'), 'cockpit-orbital-reach-details'); - - // FOCUS through the delegated listener, as a person presses it. - const focusButton = (key) => row(key).querySelector('[data-orbital-focus]'); - assert.equal(focusButton('site-alpha').getAttribute('aria-label'), 'Keep the camera on Camp Alpha'); - focusButton('site-alpha').click(); - assert.equal(cockpit.orbital.focus.key, 'site-alpha'); - await settle(); - assert.equal(focusButton('site-alpha').getAttribute('aria-pressed'), 'true'); - assert.equal(focusButton('site-bravo').getAttribute('aria-pressed'), 'false'); - // The list takes its own clicks: none reaches the picture to end the focus. - run(scene, LAND_FRAMES); - assert.ok(missDeg(scene.shots.at(-1), 'alpha') < 1e-5); - - // FOCUS on another point switches to it, turning rather than jumping. - focusButton('site-bravo').click(); - assert.equal(cockpit.orbital.focus.key, 'site-bravo'); - const largest = run(scene, LAND_FRAMES); - assert.ok(largest < MAX_TURN_PER_FRAME_DEG, `turned ${largest}° in a frame`); - assert.ok(missDeg(scene.shots.at(-1), 'bravo') < 1e-5); - await settle(); - assert.equal(focusButton('site-alpha').getAttribute('aria-pressed'), 'false'); - assert.equal(focusButton('site-bravo').getAttribute('aria-pressed'), 'true'); - - // FOCUS on the focused point ends the focus. - focusButton('site-bravo').click(); - assert.equal(cockpit.orbital.focus, null); - run(scene, LAND_FRAMES, { drift: 0 }); - assert.ok(offNadir(scene.shots.at(-1)) < 1e-6); -}); - -test('a point in frame but beyond the reach is listed without FOCUS', async (t) => { - const scene = await inOrbit(t); - // Tilt the view toward the far point with the arrows: it comes into frame - // but a focus on it would pass 45°. - const press = (key) => - scene.cockpit.onKeyDown({ - key, - target: document.body, - preventDefault() {}, - stopImmediatePropagation() {}, - }); - for (let i = 0; i < 9; i += 1) press('ArrowLeft'); - run(scene, 2 * LAND_FRAMES, { drift: 0 }); - // Rebuild the list on the next frame. - scene.cockpit.orbital.reachMs = -Infinity; - scene.frame(); - await settle(); - const far = scene.store.orbitalReach.entries.find((entry) => entry.key === 'site-far'); - assert.ok(far, 'listed while in frame'); - assert.equal(far.reachable, false); - scene.$('#cockpit-orbital-reach-toggle').click(); - await settle(); - const button = scene.$('[data-orbital-reach-key="site-far"] [data-orbital-focus]'); - assert.equal(button.getAttribute('aria-disabled'), 'true'); - assert.equal(button.title, 'Beyond the off-nadir reach'); - button.click(); - assert.equal(scene.cockpit.orbital.focus ?? null, null); -}); - -test('turning the layer off or dropping the point ends the focus', async (t) => { +test('turning the layer off ends the focus and says why; a reload around the site keeps it', async (t) => { const scene = await inOrbit(t); scene.click(SITES.alpha.at); run(scene, 5); - scene.enabled.delete(LAYER); + scene.sites.enabled = false; scene.frame(); assert.equal(scene.cockpit.orbital.focus, null, 'layer off'); assert.equal(scene.cockpit.orbital.focusReleasing, true, 'easing back'); - scene.cockpit.orbital.reachMs = -Infinity; - scene.frame(); - assert.equal(scene.store.orbitalReach.count, 0, 'nothing listed from a layer that is off'); - - scene.enabled.add(LAYER); + await refreshRows(scene); + const status = rows(scene).find((row) => row.title === 'SITES STANDBY'); + assert.ok(status, 'a status row says why no site is listed'); + assert.equal(status.detail, 'ENABLE MAPPED INSTALLATIONS FOR SITE PINGS'); + assert.equal(status.className, 'warning'); + assert.ok(!rows(scene).some((row) => row.target?.layerId === 'military-installations')); + assert.equal(rows(scene).filter((row) => row.target?.layerId === 'satellites').length, 2); + scene.sites.enabled = true; + await refreshRows(scene); + assert.ok(!rows(scene).some((row) => row.title === 'SITES STANDBY')); scene.click(SITES.bravo.at); - run(scene, 5); - getContextStore().entities.delete('site-bravo'); - scene.frame(); - assert.equal(scene.cockpit.orbital.focus, null, 'point dropped'); - - scene.click(SITES.alpha.at); - run(scene, 5); - scene.carriers.alpha.show = false; - scene.frame(); - assert.equal(scene.cockpit.orbital.focus, null, 'point hidden'); + run(scene, LAND_FRAMES); + // The layer reloads its thinned view: the site leaves its records for a + // while. It has not moved, so the focus stays on it. + const loaded = scene.sites.records; + scene.sites.records = loaded.filter((r) => r.id !== SITES.bravo.id); + run(scene, 10); + assert.equal(scene.cockpit.orbital.focus.key, SITES.bravo.id, 'kept through a reload'); + assert.ok(missDeg(scene.shots.at(-1), 'bravo') < 1e-5, 'still centred'); + scene.sites.records = loaded; + // A row the list shows works even if the layer just dropped its site. + await refreshRows(scene); + scene.sites.records = loaded.filter((r) => r.id !== SITES.alpha.id); + siteRow(scene, 'alpha').click(); + assert.equal(scene.cockpit.orbital.focus.key, SITES.alpha.id); }); -test('leaving orbit while focused clears the focus and the list', async (t) => { +test('a satellite row rides it, staying in orbit; RETURN goes back', async (t) => { + const scene = await inOrbit(t); + const { cockpit, satellites, viewer, modeEvents } = scene; + assert.ok(!rows(scene).some((row) => row.title.startsWith('RETURN')), 'no history yet'); + siteRow(scene, 'alpha').click(); + run(scene, 5); + const before = Cesium.Cartesian3.clone(scene.shots.at(-1).destination); + + satelliteRow(scene, 49260).click(); + assert.deepEqual(satellites.calls.slice(-2), [['track', 49260], ['subject', 49260]]); + assert.equal(viewer.trackedEntity, undefined, 'the Cockpit keeps the camera'); + assert.equal(cockpit.subjectId, 49260); + assert.equal(cockpit.orbital.focus, null, 'the focus is reset'); + assert.equal(modeEvents.at(-1)?.active, true); + await settle(); + const shown = rows(scene); + assert.deepEqual([shown[0].title, shown[0].detail], ['TRACK ACQUIRED', 'LANDSAT 9 · NADIR VIEW']); + assert.equal(shown.filter((row) => row.title === 'TRACK ACQUIRED').length, 1); + const back = shown.find((row) => row.title === 'RETURN · ISS (ZARYA)'); + assert.ok(back, 'a RETURN row while there is history'); + assert.equal(back.detail, 'PREVIOUS SATELLITE'); + assert.equal(satelliteRow(scene, 25544).getAttribute('aria-label'), 'Return to ISS (ZARYA)'); + assert.ok(!shown.some((row) => row.title === 'ISS (ZARYA)'), 'listed once, as RETURN'); + + // The camera hops along the way, never in one jump. + const target = satellites.position(49260); + const hopM = Cesium.Cartesian3.distance(before, target); + let previous = before; + let largestStep = 0; + for (let i = 0; i < Math.ceil(ORBITAL_HOP_MS / FRAME_MS) + 2; i += 1) { + const shot = scene.frame(); + assert.equal(cockpit.active, true, `still in orbit at frame ${i}`); + largestStep = Math.max(largestStep, Cesium.Cartesian3.distance(previous, shot.destination)); + previous = shot.destination; + } + assert.ok(largestStep < hopM * 0.1, `largest step ${largestStep} m of ${hopM} m`); + assert.ok(Cesium.Cartesian3.distance(previous, target) < 1, 'landed on the satellite'); + run(scene, LAND_FRAMES, { drift: 0 }); + assert.ok(offNadir(scene.shots.at(-1)) < 1e-6, 'looking at nadir'); + + // On to another, then RETURN twice back to the start. + await refreshRows(scene); + satelliteRow(scene, 40697).click(); + assert.equal(cockpit.subjectId, 40697); + await settle(); + assert.ok(rows(scene).some((row) => row.title === 'RETURN · LANDSAT 9')); + satelliteRow(scene, 49260).click(); + assert.equal(cockpit.subjectId, 49260); + await settle(); + satelliteRow(scene, 25544).click(); + assert.equal(cockpit.subjectId, 25544); + await settle(); + assert.ok(!rows(scene).some((row) => row.title.startsWith('RETURN')), 'back at the start'); + scene.frame(); + assert.equal(cockpit.active, true); + assert.ok(!satellites.calls.some(([, id]) => id === 99001), 'debris never offered'); +}); + +test('while the camera hops, no stale rows; on landing, rows measured from the new satellite', async (t) => { + const scene = await inOrbit(t); + const { cockpit } = scene; + satelliteRow(scene, 49260).click(); + await settle(); + // Mid-hop: TRACK ACQUIRED and RETURN only, nothing measured from the old + // satellite's frame (which would list LANDSAT 9 itself at 0 km). + let shown = rows(scene); + assert.deepEqual( + shown.map((row) => row.title), + ['TRACK ACQUIRED', 'RETURN · ISS (ZARYA)'], + ); + scene.frame(); + scene.refreshCard(); + await settle(); + assert.ok(cockpit.orbital.hop, 'still hopping'); + assert.deepEqual(rows(scene).map((row) => row.title), ['TRACK ACQUIRED', 'RETURN · ISS (ZARYA)']); + // Landed: the first frame on the satellite rebuilds them at once. + for (let i = 0; i < Math.ceil(ORBITAL_HOP_MS / FRAME_MS) + 2; i += 1) scene.frame(); + await settle(); + shown = rows(scene); + assert.ok(shown.some((row) => row.target?.layerId === 'military-installations'), 'sites back'); + const satellitesShown = shown.filter((row) => row.title !== 'TRACK ACQUIRED' && row.target?.layerId === 'satellites'); + assert.ok(!satellitesShown.some((row) => row.title === 'LANDSAT 9'), 'never the ridden one'); + assert.equal(satellitesShown.filter((row) => row.target.id === '25544').length, 1, 'ISS only as RETURN'); +}); + +test('RETURN to a satellite that left the catalogue offers the one before it', async (t) => { + const scene = await inOrbit(t); + const { cockpit, satellites } = scene; + satelliteRow(scene, 49260).click(); + for (let i = 0; i < 80; i += 1) scene.frame(); + await settle(); + satelliteRow(scene, 40697).click(); + for (let i = 0; i < 80; i += 1) scene.frame(); + await settle(); + assert.ok(rows(scene).some((row) => row.title === 'RETURN · LANDSAT 9')); + satellites.remove(49260); + satelliteRow(scene, 49260).click(); + assert.equal(cockpit.subjectId, 40697, 'still on the same satellite'); + await settle(); + assert.ok(rows(scene).some((row) => row.title === 'RETURN · ISS (ZARYA)'), 'the one before'); + satelliteRow(scene, 25544).click(); + assert.equal(cockpit.subjectId, 25544); +}); + +test('with Installations off no site is offered, by row, list or click', async (t) => { + const scene = await inOrbit(t); + const { cockpit, sites } = scene; + // The real layer keeps its records and getNearby answers while off. + sites.enabled = false; + await refreshRows(scene); + assert.ok(rows(scene).some((row) => row.title === 'SITES STANDBY')); + assert.equal(cockpit.orbital.listedSites.size, 0, 'nothing kept to focus from'); + assert.equal(cockpit.toggleOrbitalFocus(SITES.alpha.id), false); + scene.click(SITES.alpha.at); + assert.equal(cockpit.orbital.focus ?? null, null); +}); + +test('an arrow during the ease back from a focus turns on the arrows’ clock', async (t) => { + const scene = await inOrbit(t); + const { cockpit } = scene; + siteRow(scene, 'alpha').click(); + run(scene, LAND_FRAMES); + scene.click(EMPTY_GROUND); + scene.frame(); + assert.equal(cockpit.orbital.focusReleasing, true); + cockpit.onKeyDown({ + key: 'ArrowUp', + target: document.body, + preventDefault() {}, + stopImmediatePropagation() {}, + }); + assert.equal(cockpit.orbital.focusReleasing, false); +}); + +test('leaving orbit leaves no orbital rows to act on', async (t) => { const scene = await inOrbit(t); scene.click(SITES.alpha.at); run(scene, 5); - scene.drop(); + scene.satellites.drop(); scene.frame(); assert.equal(scene.cockpit.active, false); - assert.equal(scene.store.orbitalReach, null); - assert.equal(scene.store.orbital, null); - // Late clicks and presses after the exit do nothing. + assert.equal(scene.store.presence.context, false); scene.click(SITES.bravo.at); - assert.equal(scene.cockpit.toggleOrbitalFocus('site-bravo'), false); + assert.equal(scene.cockpit.toggleOrbitalFocus(SITES.bravo.id), false); assert.equal(scene.cockpit.onOrbitalClick(SITES.bravo.at), false); - assert.equal(scene.handler.stockClicks, 2, 'Cesium kept its clicks'); + assert.equal(scene.cockpit.rideOrbitalSatellite(49260), false); + assert.equal(scene.cockpit.handleOrbitalSignalClick('satellites', '49260'), false); }); test('NADIR and Home end a focus; an arrow takes the view over where it is', async (t) => { @@ -625,85 +833,113 @@ test('NADIR and Home end a focus; an arrow takes the view over where it is', asy $('#cockpit-orbital-nadir').click(); assert.equal(cockpit.orbital.focus, null); assert.ok(offNadir(scene.frame()) > 5, 'eases back'); - scene.click(SITES.alpha.at); run(scene, LAND_FRAMES); press('Home'); assert.equal(cockpit.orbital.focus, null); - scene.click(SITES.alpha.at); run(scene, LAND_FRAMES); const held = { ...cockpit.orbital.lookView }; press('ArrowUp'); assert.equal(cockpit.orbital.focus, null); - // The step continues from the focused look, on the 5° grid. - assert.equal(cockpit.orbital.lookTarget.pitchDeg, Math.floor(held.pitchDeg / 5 + 1e-9) * 5 + 5); - const largest = run(scene, LAND_FRAMES, { drift: 0 }); - assert.ok(largest < MAX_TURN_PER_FRAME_DEG); + assert.equal( + cockpit.orbital.lookTarget.pitchDeg, + Math.floor(held.pitchDeg / 5 + 1e-9) * 5 + 5, + ); }); -test('reduced motion cuts onto the point and back', async (t) => { +test('reduced motion cuts onto the site, back, and between satellites', async (t) => { const scene = await inOrbit(t, { reducedMotion: true }); + await settle(); scene.click(SITES.alpha.at); assert.ok(missDeg(scene.frame(), 'alpha') < 1e-5, 'on it at once'); scene.click(EMPTY_GROUND); assert.ok(offNadir(scene.frame()) < 1e-6, 'back at once'); + satelliteRow(scene, 49260).click(); + const shot = scene.frame(); + assert.ok(Cesium.Cartesian3.distance(shot.destination, scene.satellites.position(49260)) < 1); }); -test('stopping the Cockpit hands the single click back to Cesium', async (t) => { +test('stopping the Cockpit hands both clicks back to Cesium', async (t) => { const scene = fixture(t); - const type = Cesium.ScreenSpaceEventType.LEFT_CLICK; - assert.notEqual(scene.handler.getInputAction(type), scene.handler.stock); + const click = Cesium.ScreenSpaceEventType.LEFT_CLICK; + const double = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK; + assert.notEqual(scene.handler.getInputAction(click), scene.handler.stock); scene.cockpit.stop(); - assert.equal(scene.handler.getInputAction(type), scene.handler.stock); - scene.click(EMPTY_GROUND); - assert.equal(scene.handler.stockClicks, 1); - assert.equal(scene.cockpit.orbital, null); + assert.equal(scene.handler.getInputAction(click), scene.handler.stock); + assert.equal(scene.handler.getInputAction(double), scene.handler.stockDouble); }); -test('a double-click on a mapped point focuses it and keeps the orbital view', async (t) => { +test('the focused site keeps its row after nearer sites push it down', async (t) => { const scene = await inOrbit(t); - const { cockpit, viewer, handler } = scene; - scene.doubleClick(SITES.alpha.at); - assert.equal(handler.stockDoubleClicks, 0, 'Cesium does not track the point'); - assert.equal(viewer.trackedEntity, undefined); - scene.frame(); - assert.equal(cockpit.active, true, 'still in orbit'); - assert.equal(cockpit.orbital.focus.key, 'site-alpha'); + siteRow(scene, 'alpha').click(); run(scene, LAND_FRAMES); - assert.ok(missDeg(scene.shots.at(-1), 'alpha') < 1e-5); - - // On the focused point it keeps the focus; on another it switches. - scene.doubleClick(SITES.alpha.at); - assert.equal(cockpit.orbital.focus.key, 'site-alpha'); - scene.doubleClick(SITES.bravo.at); - assert.equal(cockpit.orbital.focus.key, 'site-bravo'); - // A mapped point beyond the reach is not focused, but not tracked either. - scene.doubleClick(SITES.far.at); - assert.equal(handler.stockDoubleClicks, 0); - scene.frame(); - assert.equal(cockpit.active, true); - - // Anywhere else the double-click goes on to Cesium's, as before. - scene.doubleClick(EMPTY_GROUND); - assert.equal(handler.stockDoubleClicks, 1); - assert.equal(cockpit.orbital.focus, null, 'the clicks ended the focus'); + // Eight more sites appear right below the satellite, all nearer to nadir. + for (let i = 0; i < 8; i += 1) + scene.sites.records.push({ + id: `osm:military:way/9${i}`, + osmKey: `way/9${i}`, + name: `Near ${i}`, + class: 'base', + kind: 'installation', + latitude: 30.4 + i * 0.01, + longitude: -96.6, + memberNames: [], + sources: [], + }); + await refreshRows(scene); + const sitesShown = rows(scene).filter((row) => row.target?.layerId === 'military-installations'); + assert.equal(sitesShown.length, 2); + assert.equal(sitesShown[0].target.id, SITES.alpha.id, 'the focused site is kept, first'); + siteRow(scene, 'alpha').click(); + assert.equal(scene.cockpit.orbital.focus, null, 'its row still ends the focus'); }); -test('a double-click a tool owns, or one outside orbit, goes on to Cesium', async (t) => { - const scene = fixture(t); - const type = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK; - scene.doubleClick(SITES.alpha.at); - assert.equal(scene.handler.stockDoubleClicks, 1, 'outside orbit'); - scene.viewer.trackedEntity = undefined; - assert.equal(scene.cockpit.enter(), true); +test('a site the layer drops in a reload keeps its row, and its row still focuses it', async (t) => { + const scene = await inOrbit(t); await settle(); - scene.frame(); - const lease = claimPointer('draw'); - t.after(() => releasePointer(lease)); - scene.doubleClick(SITES.alpha.at); - assert.equal(scene.handler.stockDoubleClicks, 2, 'a tool owns it'); - releasePointer(lease); - scene.cockpit.stop(); - assert.equal(scene.handler.getInputAction(type), scene.handler.stockDouble); + const before = rows(scene).map((row) => row.key); + const loaded = scene.sites.records; + // The thinned view reloads without Camp Alpha for a moment. + scene.sites.records = loaded.filter((r) => r.id !== SITES.alpha.id); + await refreshRows(scene); + assert.deepEqual(rows(scene).map((row) => row.key), before, 'the rows hold still'); + siteRow(scene, 'alpha').click(); + assert.equal(scene.cockpit.orbital.focus?.key, SITES.alpha.id, 'focused from its row'); + siteRow(scene, 'alpha').click(); + // Gone for good: after a while it leaves the rows. + for (let i = 0; i < 12; i += 1) await refreshRows(scene); + assert.ok(!rows(scene).some((row) => row.target?.id === SITES.alpha.id), 'forgotten later'); +}); + +test('shown rows keep their places while they still qualify', async (t) => { + const scene = await inOrbit(t); + await settle(); + const sitesBefore = rows(scene) + .filter((row) => row.target?.layerId === 'military-installations') + .map((row) => row.target.id); + // A site appears right below the satellite, nearer to nadir than both. + scene.sites.records.push({ + id: 'osm:military:way/999', + osmKey: 'way/999', + name: 'Directly Below', + class: 'base', + kind: 'installation', + latitude: 30.01, + longitude: -97.01, + memberNames: [], + sources: [], + }); + await refreshRows(scene); + const sitesAfter = rows(scene) + .filter((row) => row.target?.layerId === 'military-installations') + .map((row) => row.target.id); + assert.deepEqual(sitesAfter, sitesBefore, 'no row moves while its site is in reach'); + // Once a shown site leaves the list, the nearer one takes its slot. + scene.sites.records = scene.sites.records.filter((r) => r.id !== SITES.bravo.id); + for (let i = 0; i < 12; i += 1) await refreshRows(scene); + assert.ok( + rows(scene).some((row) => row.target?.id === 'osm:military:way/999'), + 'the nearer site fills the freed slot', + ); }); diff --git a/src/ui/cockpitOrbitalSatellites.js b/src/ui/cockpitOrbitalSatellites.js new file mode 100644 index 000000000..c58c50d7f --- /dev/null +++ b/src/ui/cockpitOrbitalSatellites.js @@ -0,0 +1,188 @@ +/** + * The satellites around the one the Cockpit's orbital view rides: the + * nearest ones for the Live Signals rows, and riding one of them (or going + * back to the one before) from its row. The view stays in orbit: the + * satellites layer tracks the + * new satellite, the Cockpit takes the camera straight back, any ground + * focus ends, and the camera hops along the great circle to the new + * satellite. Bound to the Cockpit controller like the orbital view itself. + */ +import * as Cesium from 'cesium'; +import { t } from '../i18n/index.js'; +import { zoomTransitionProgress } from '../cockpitOrbital.js'; +import { + ORBITAL_HOP_MS, + ORBITAL_SATELLITES_MAX, + hopPosition, +} from '../cockpitOrbitalSatellites.js'; + +/** Whether the user asked for reduced motion: hops then cut. */ +function prefersReducedMotion() { + return Boolean( + globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches, + ); +} + +function inOrbit(controller) { + return ( + controller.active && + !controller.destroyed && + controller.subjectKind === 'satellite' && + Boolean(controller.orbital) + ); +} + +/** The satellites ridden in this orbital visit, oldest first. */ +function visits(controller) { + const state = controller.orbital; + if (!Array.isArray(state.visits)) state.visits = []; + const current = controller.subjectId; + if (current !== null && current !== undefined && !state.visits.length) + state.visits.push(current); + return state.visits; +} + +/** + * The satellites nearest the ridden one, nearest first (debris left out): + * the layer's bounded nearest query, measured from the satellite itself + * (not the camera, which may still be hopping to it). + * @returns {Array} As the layer's `getNearestPositions`. + */ +export function orbitalNearbySatellites() { + const layer = this.services?.satellitesLayer; + if (typeof layer?.getNearestPositions !== 'function') return []; + try { + const from = layer.getCockpitPosition?.(); + if (!from) return []; + return ( + layer.getNearestPositions(from, ORBITAL_SATELLITES_MAX, { + excludeIds: [this.subjectId], + excludeClasses: ['debris'], + }) || [] + ); + } catch { + return []; + } +} + +/** + * The satellite ridden before this one in this orbital visit, or null. + * @returns {number|string|null} + */ +export function previousOrbitalSatellite() { + if (!inOrbit(this)) return null; + const ridden = visits(this); + return ridden.length > 1 ? ridden[ridden.length - 2] : null; +} + +/** + * Ride the satellite a Live Signals row names: back to the one ridden before + * (its RETURN row), or on to another, remembered so RETURN can come back. + * @param {number|string} noradId + * @returns {boolean} True when the ridden satellite changed. + */ +export function rideOrbitalSatellite(noradId) { + if (!inOrbit(this)) return false; + const ridden = visits(this); + const back = ridden.length > 1 ? ridden[ridden.length - 2] : null; + if (back !== null && String(back) === String(noradId)) { + if (!switchOrbitalSatellite.call(this, back)) { + // It left the catalogue: forget it, so RETURN offers the one before. + ridden.splice(ridden.length - 2, 1); + this.refreshOrbitalSignals?.(); + return false; + } + ridden.pop(); + } else { + if (!switchOrbitalSatellite.call(this, noradId)) return false; + ridden.push(this.subjectId); + } + // The RETURN row follows the history just changed. + this.refreshOrbitalSignals?.(); + return true; +} + +/** + * Ride another satellite without leaving orbit. The satellites layer tracks + * it (as a click on its point would); the Cockpit takes the camera back at + * once, ends any ground focus with the view back at nadir, and starts the + * hop from where the camera is. + * @param {number|string} noradId + * @returns {boolean} True when the ridden satellite changed. + */ +export function switchOrbitalSatellite(noradId) { + if (!inOrbit(this)) return false; + if (String(noradId) === String(this.subjectId)) return false; + const layer = this.services?.satellitesLayer; + const state = this.orbital; + const from = state.focusFrame?.position ?? null; + let tracked = false; + try { + tracked = Boolean(layer?.trackById?.(noradId, { origin: 'user' })); + } catch { + tracked = false; + } + // Tracking hands the camera to Cesium's follow; the orbital view keeps it. + this.viewer.trackedEntity = undefined; + const info = tracked ? this.readSatelliteInfo() : null; + if (!info || String(info.noradId) !== String(noradId)) { + // Nothing changed hands, or the layer could not take it: the view ends + // on the next frame rather than ride something it did not ask for. + return false; + } + layer.setCockpitSubject?.(info.noradId); + this.subjectId = info.noradId; + state.focus = null; + state.focusReleasing = !prefersReducedMotion(); + state.lookTarget = { yawDeg: 0, pitchDeg: 0 }; + if (!state.focusReleasing) state.lookView = { yawDeg: 0, pitchDeg: 0 }; + state.hop = + from && !prefersReducedMotion() + ? { from: { ...from }, startMs: performance.now() } + : null; + // The rows measure from the camera's frame, still the old satellite's: + // until the camera is on the new one, they list no sites or satellites. + state.awaitingFrame = true; + state.previousUp = null; + state.place = undefined; + state.placeTracker?.reset?.(); + state.prefetched?.clear?.(); + this.lastHudUpdateMs = 0; + this.lastContextUpdateMs = 0; + this.dispatchCockpitModeChanged(true, info); + this.pushCockpitSignal( + 'track', + 'track', + t('TRACK ACQUIRED'), + t('{label} · NADIR VIEW', { label: info.name }), + ); + this.updateOrbitalHud(info); + this.refreshOrbitalSignals?.(); + return true; +} + +/** + * Where the camera is this frame: on the satellite, or part-way along a hop + * to it, eased like a zoom step. The hop ends when it lands. + * @param {object} state The orbital session. + * @param {Cesium.Cartesian3} position The ridden satellite this frame. + * @param {number} nowMs + * @returns {Cesium.Cartesian3} + */ +export function orbitalEyePosition(state, position, nowMs) { + const hop = state.hop; + if (!hop) return position; + const progress = zoomTransitionProgress(nowMs - hop.startMs, { + durationMs: ORBITAL_HOP_MS, + }); + if (progress >= 1) { + state.hop = null; + return position; + } + const eye = hopPosition(hop.from, position, progress); + if (!eye) { + state.hop = null; + return position; + } + return new Cesium.Cartesian3(eye.x, eye.y, eye.z); +} diff --git a/src/ui/cockpitOrbitalSignals.js b/src/ui/cockpitOrbitalSignals.js new file mode 100644 index 000000000..c01bd56a2 --- /dev/null +++ b/src/ui/cockpitOrbitalSignals.js @@ -0,0 +1,260 @@ +/** + * The Live Signals rows in the orbital view. Orbit feeds no Contacts + * snapshot, so the rows are built here, the way the aircraft rows are built + * from one (src/ui/cockpitSignals.js): the TRACK ACQUIRED row, the nearest + * mapped sites in reach (a row focuses its site, or releases the one held), + * a RETURN row to the satellite ridden before, and the nearest satellites (a + * row rides it). With Mapped Installations off a status row says why no + * site is listed. Five rows at most, with stable keys, rebuilt on their own + * clock. Bound to the Cockpit controller. + */ +import { formatNumber, t } from '../i18n/index.js'; +import { ORBITAL_REACH_REFRESH_MS } from '../cockpitOrbitalFocus.js'; +import { bearingBetweenCoordinates, relativeBearing } from '../cockpitMath.js'; +import { + installationClassLabel, + installationTitle, +} from '../layers/installations/classLabel.js'; +import { formatCockpitDistance } from './cockpitPresentation.js'; +import { orbitalSitesInReach } from './cockpitOrbitalFocus.js'; +import { + orbitalNearbySatellites, + previousOrbitalSatellite, +} from './cockpitOrbitalSatellites.js'; + +/** The most rows Live Signals shows, as for aircraft. */ +const SIGNAL_ROWS = 5; + +/** Site rows when there are satellites to list too. */ +const SITE_ROWS = 2; + +const SITES_LAYER_ID = 'military-installations'; + +function inOrbit(controller) { + return ( + controller.active && + !controller.destroyed && + controller.subjectKind === 'satellite' && + Boolean(controller.orbital) + ); +} + +function sitesLayerOn(controller) { + const isLayerEnabled = controller.services?.isLayerEnabled; + return typeof isLayerEnabled === 'function' + ? isLayerEnabled(SITES_LAYER_ID) !== false + : false; +} + +/** Where another satellite is, from the top of the frame, worded. */ +function bearingWords(info, satellite) { + const bearing = bearingBetweenCoordinates( + info.latitude, + info.longitude, + satellite.latitude, + satellite.longitude, + ); + // Relative to the top of the frame: the ground track, or north. + const relative = relativeBearing( + bearing, + Number.isFinite(info.groundTrackDeg) ? info.groundTrackDeg : 0, + ); + if (relative === null) return t('BRG —'); + if (Math.abs(relative) < 8) return t('AHEAD'); + return t('{side} {angle}', { + side: relative < 0 ? t('L') : t('R'), + angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`, + }); +} + +/** The ids the rows of one layer show now, in their order. */ +function shownIds(controller, layerId, keyPrefix) { + return controller.signalItems + .filter((item) => item.key.startsWith(keyPrefix)) + .filter((item) => item.target?.layerId === layerId) + .map((item) => String(item.target.id)); +} + +/** + * Order rows so those already shown keep their places while they still + * qualify; new ones fill after them, nearest first. Sites pass under a low + * orbit in seconds: re-sorting by angle every beat would move a row away + * from the pointer reaching for it. + * @param {Array} entries Nearest first. + * @param {(entry: object) => string} keyOf + * @param {Array} shown Ids shown now, in order. + * @param {string|null} [first] An id that goes first (the focused site). + */ +function stickyOrder(entries, keyOf, shown, first = null) { + const byKey = new Map(entries.map((entry) => [keyOf(entry), entry])); + const ordered = []; + const take = (key) => { + const entry = byKey.get(key); + if (!entry) return; + ordered.push(entry); + byKey.delete(key); + }; + if (first !== null) take(first); + for (const key of shown) take(key); + for (const entry of entries) take(keyOf(entry)); + return ordered; +} + +/** The site rows: the focused site first, then those shown, then the nearest. */ +function siteRows(info, { settled = true } = {}) { + if (!sitesLayerOn(this)) { + // Also forgets the sites last listed, so none is focused from a row. + orbitalSitesInReach.call(this, info); + return [ + { + key: 'site-status', + tone: 'warning', + title: t('SITES STANDBY'), + detail: t('ENABLE MAPPED INSTALLATIONS FOR SITE PINGS'), + target: null, + }, + ]; + } + if (!settled) return []; + const { entries } = orbitalSitesInReach.call(this, info); + const focused = entries.find((entry) => entry.focused); + return stickyOrder( + entries, + (entry) => String(entry.site.id), + shownIds(this, SITES_LAYER_ID, 'site:'), + focused ? String(focused.site.id) : null, + ).map(({ site, offNadirDeg, focused }) => { + const title = installationTitle(site.name) || t('Unnamed site'); + const angle = t('OFF-NADIR {angle}°', { + angle: formatNumber(offNadirDeg, { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }), + }); + const words = [installationClassLabel(site.class), angle]; + if (focused) words.unshift(t('FOCUS')); + return { + key: `site:${site.id}`, + // The held site reads as the subject does. + tone: focused ? 'track' : 'nearby', + title, + detail: words.join(' · '), + target: { + layerId: SITES_LAYER_ID, + id: String(site.id), + label: focused + ? t('Release the camera from {name}', { name: title }) + : t('Keep the camera on {name}', { name: title }), + }, + }; + }); +} + +/** The satellite rows: RETURN to the one before, then the nearest. */ +function satelliteRows(info) { + const state = this.orbital; + state.satelliteNames ??= new Map(); + state.satelliteNames.set(String(info.noradId), info.name); + const rows = []; + const back = previousOrbitalSatellite.call(this); + if (back !== null) { + const name = state.satelliteNames.get(String(back)) ?? String(back); + rows.push({ + key: `satellite-return:${back}`, + tone: 'nearby', + title: t('RETURN · {name}', { name }), + detail: t('PREVIOUS SATELLITE'), + target: { + layerId: 'satellites', + id: String(back), + label: t('Return to {name}', { name }), + }, + }); + } + // Measured only once the camera is on the ridden satellite. + const measured = !state.hop && !state.awaitingFrame; + const nearby = measured + ? stickyOrder( + orbitalNearbySatellites.call(this), + (satellite) => String(satellite.id), + shownIds(this, 'satellites', 'satellite:'), + ) + : []; + for (const satellite of nearby) { + if (back !== null && String(satellite.id) === String(back)) continue; + const name = String(satellite.label ?? satellite.id); + state.satelliteNames.set(String(satellite.id), name); + rows.push({ + key: `satellite:${satellite.id}`, + tone: 'nearby', + title: name, + detail: [ + t('SATELLITE'), + bearingWords(info, satellite), + formatCockpitDistance(satellite.distanceM), + ].join(' · '), + target: { + layerId: 'satellites', + id: String(satellite.id), + label: t('Ride {name}', { name }), + }, + }); + } + return rows; +} + +/** + * Rebuild the orbital Live Signals rows now: after a focus change, a + * satellite switch, or on their clock. TRACK ACQUIRED stays first; sites + * take two rows when satellites fill the rest, more when they do not. + */ +export function refreshOrbitalSignals() { + if (!inOrbit(this)) return; + const info = this.lastSubjectInfo; + if (info?.kind !== 'satellite') return; + this.orbital.signalsMs = performance.now(); + const previous = new Map(this.signalItems.map((item) => [item.key, item])); + const rows = []; + const track = this.signalItems.find((item) => item.key === 'track'); + if (track) rows.push(track); + // While the camera hops to a new satellite its frame is not the + // satellite's yet: the site rows wait for it (the status row does not). + const settled = !this.orbital.hop && !this.orbital.awaitingFrame; + const sites = siteRows.call(this, info, { settled }); + const satellites = satelliteRows.call(this, info); + const free = SIGNAL_ROWS - rows.length; + rows.push(...sites.slice(0, Math.max(SITE_ROWS, free - satellites.length))); + rows.push(...satellites.slice(0, SIGNAL_ROWS - rows.length)); + const now = Date.now(); + this.signalItems = rows.map((row) => ({ + ...row, + timestamp: previous.get(row.key)?.timestamp ?? row.timestamp ?? now, + })); + this.signalSignatures.clear(); + this.publishCockpitSignals(); +} + +/** + * The rows' clock: rebuild once every ORBITAL_REACH_REFRESH_MS. + * @param {number} nowMs + */ +export function updateOrbitalSignals(nowMs) { + const state = this.orbital; + if (!state) return; + if (nowMs - (state.signalsMs ?? -Infinity) < ORBITAL_REACH_REFRESH_MS) return; + refreshOrbitalSignals.call(this); +} + +/** + * A click on an orbital row: a site row focuses its site or releases it, a + * satellite row rides that satellite (RETURN goes back). + * @param {string} layerId + * @param {string} id + * @returns {boolean} True in orbit, where the row is this module's. + */ +export function handleOrbitalSignalClick(layerId, id) { + if (!inOrbit(this)) return false; + if (layerId === SITES_LAYER_ID) this.toggleOrbitalFocus(id); + else if (layerId === 'satellites') this.rideOrbitalSatellite(id); + return true; +} diff --git a/src/ui/cockpitOrbitalView.js b/src/ui/cockpitOrbitalView.js index 50e90d007..8e6f20b06 100644 --- a/src/ui/cockpitOrbitalView.js +++ b/src/ui/cockpitOrbitalView.js @@ -44,11 +44,11 @@ import { isPointerFree } from '../data/inputOwnership.js'; import { zoomableSwathKm } from '../layers/satellites/sensors.js'; import { easeOrbitalLook, - endOrbitalFocus, orbitalFocusReadout, releaseOrbitalFocus, updateOrbitalFocus, } from './cockpitOrbitalFocus.js'; +import { orbitalEyePosition } from './cockpitOrbitalSatellites.js'; import { COCKPIT_CONTEXT_UPDATE_MS, COCKPIT_HUD_UPDATE_MS, @@ -137,7 +137,6 @@ export function endOrbital() { // Back to the map the operator had, unless they picked another meanwhile. state.imagery?.release?.()?.catch?.(() => {}); this.publishOrbital(null); - endOrbitalFocus.call(this); } /** @@ -306,9 +305,11 @@ export function updateOrbital(nowMs) { ); if (!position) return; const state = this.orbital; + // On the satellite, or on the way to it after Previous/Next. + const eye = orbitalEyePosition(state, position, nowMs); // Track-up: the ground track at the top, so the picture scrolls along it. // A geostationary satellite has no track and stays north-up. - const nadir = nadirOrientation(position, { + const nadir = nadirOrientation(eye, { previousUp: state.previousUp, headingDeg: Number.isFinite(info.groundTrackDeg) ? info.groundTrackDeg @@ -326,7 +327,7 @@ export function updateOrbital(nowMs) { goToZoom.call(this, maxZoom); } // A ground focus aims the look itself (src/ui/cockpitOrbitalFocus.js). - if (!updateOrbitalFocus.call(this, state, nadir, position, nowMs)) + if (!updateOrbitalFocus.call(this, state, nadir, eye, nowMs)) state.lookView = easeOrbitalLook( state, nowMs - (state.lookLastMs || nowMs), @@ -340,7 +341,7 @@ export function updateOrbital(nowMs) { const zoomStepping = Boolean(state.transition); const reading = applyFov.call(this, state, info.altitudeM, nowMs); this.viewer.camera.setView({ - destination: position, + destination: eye, orientation: { direction: intoCartesian(view.direction, this.scratchForward), up: intoCartesian(view.up, this.scratchUp), diff --git a/src/ui/cockpitSignals.js b/src/ui/cockpitSignals.js index 32f25bdc8..54889a82d 100644 --- a/src/ui/cockpitSignals.js +++ b/src/ui/cockpitSignals.js @@ -28,7 +28,10 @@ export function publishCockpitSignals() { target: item.target && { layerId: item.target.layerId, id: item.target.id, - label: t('Select flight {title}', { title: item.title }), + // Orbit's site and satellite rows name their own action. + label: + item.target.label ?? + t('Select flight {title}', { title: item.title }), }, }; }), @@ -212,6 +215,14 @@ export function handleSignalClick(event) { ); if (!target) return; event.preventDefault(); + // In orbit the rows are sites and satellites (cockpitOrbitalSignals.js). + if ( + this.handleOrbitalSignalClick?.( + target.dataset.signalLayer, + target.dataset.signalId, + ) + ) + return; this.services.militaryAwarenessLayer.focusTarget?.( target.dataset.signalLayer, target.dataset.signalId, diff --git a/src/ui/stores/cockpit.js b/src/ui/stores/cockpit.js index a71a78336..4a54742e1 100644 --- a/src/ui/stores/cockpit.js +++ b/src/ui/stores/cockpit.js @@ -132,22 +132,6 @@ export const useCockpitStore = defineStore('gev-cockpit', () => { orbital.value = next && markRaw(next); } - /** - * The mapped points the orbital view shows or can be turned to: null - * outside orbit. Shape: `{ count, focusKey|null, entries: [{ key, label, - * layerName, source, latitude, longitude, offNadirDeg, reachable, - * focused, osm, details: [{ key, value }] }] }`, nearest to nadir first - * and at most 12 (`count` is all of them); `reachable` is false for a - * point in the frame but beyond the off-nadir reach, `osm` marks details - * that are OpenStreetMap tags (src/ui/cockpitOrbitalFocus.js). - */ - const orbitalReach = shallowRef(null); - - /** @param {?object} next A fresh list of the mapped points in reach. */ - function publishOrbitalReach(next) { - orbitalReach.value = next && markRaw(next); - } - /** * The utility strip's measured layout: its top and height limits as CSS * lengths (null leaves the stylesheet's), whether only the expanded control @@ -187,8 +171,6 @@ export const useCockpitStore = defineStore('gev-cockpit', () => { publishRadar, orbital, publishOrbital, - orbitalReach, - publishOrbitalReach, utilityLayout, publishUtilityLayout, }; diff --git a/src/ui/styles/cockpitOrbitalFocus.css b/src/ui/styles/cockpitOrbitalFocus.css new file mode 100644 index 000000000..0815c3159 --- /dev/null +++ b/src/ui/styles/cockpitOrbitalFocus.css @@ -0,0 +1,11 @@ +/* The site a ground focus holds, named under the off-nadir readout + (src/ui/views/panels/CockpitOrbital.vue). Empty, it takes no room. */ +.cockpit-orbital-focus:not(:empty) { + margin-top: 4px; + overflow: hidden; + color: #f4fbff; + font-weight: 700; + letter-spacing: 0.1em; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/ui/styles/cockpitOrbitalReach.css b/src/ui/styles/cockpitOrbitalReach.css deleted file mode 100644 index fede7254e..000000000 --- a/src/ui/styles/cockpitOrbitalReach.css +++ /dev/null @@ -1,150 +0,0 @@ -/* The orbital view's ground focus (src/ui/views/panels/CockpitOrbital.vue, - CockpitOrbitalReach.vue): the focused target under the off-nadir readout - and, at the foot of the readout card, the mapped points in reach. The HUD - passes clicks through to the picture; the list takes its own, so a click - on it never ends a focus. */ -.cockpit-orbital-focus:not(:empty) { - margin-top: 4px; - overflow: hidden; - color: #f4fbff; - font-weight: 700; - letter-spacing: 0.1em; - text-overflow: ellipsis; - white-space: nowrap; -} -.cockpit-orbital-reach { - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid var(--orbital-faint); - pointer-events: auto; -} -.cockpit-orbital-reach-toggle, -.cockpit-orbital-reach-focus { - padding: 2px 7px; - border: 1px solid var(--orbital-line); - border-radius: 2px; - background: rgba(0, 7, 12, 0.6); - color: #f4fbff; - font: inherit; - font-weight: 700; - letter-spacing: 0.12em; - cursor: pointer; -} -.cockpit-orbital-reach-toggle[aria-expanded='true'], -.cockpit-orbital-reach-focus[aria-pressed='true'], -.cockpit-orbital-reach-toggle:hover, -.cockpit-orbital-reach-toggle:focus-visible, -.cockpit-orbital-reach-focus:hover, -.cockpit-orbital-reach-focus:focus-visible { - border-color: var(--cockpit-accent, #00dcff); - color: #fff; - outline: none; -} -.cockpit-orbital-reach-focus[aria-pressed='true'] { - background: color-mix( - in srgb, - var(--cockpit-accent, #00dcff) 22%, - rgba(0, 7, 12, 0.8) - ); -} -.cockpit-orbital-reach-focus[aria-disabled='true'] { - border-color: rgba(244, 251, 255, 0.12); - color: rgba(244, 251, 255, 0.32); - cursor: default; -} -.cockpit-orbital-reach-body { - max-height: min(30vh, 260px); - margin-top: 6px; - overflow-y: auto; - overscroll-behavior: contain; - scrollbar-width: thin; -} -.cockpit-orbital-reach-list { - display: grid; - gap: 3px; - margin: 0; - padding: 0; - list-style: none; -} -.cockpit-orbital-reach-row { - display: flex; - align-items: center; - gap: 6px; -} -.cockpit-orbital-reach-name { - display: grid; - flex: 1; - min-width: 0; - padding: 3px 6px; - border: 1px solid transparent; - border-inline-start-color: var(--orbital-faint); - background: rgba(0, 7, 12, 0.34); - color: #f4fbff; - font: inherit; - text-align: start; - cursor: pointer; -} -.cockpit-orbital-reach-name strong, -.cockpit-orbital-reach-name small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.cockpit-orbital-reach-name small { - color: rgba(244, 251, 255, 0.56); - font-size: 9px; - font-variant-numeric: tabular-nums; -} -.cockpit-orbital-reach-name[aria-expanded='true'], -.cockpit-orbital-reach-name:hover, -.cockpit-orbital-reach-name:focus-visible { - border-color: var(--orbital-line); - outline: none; -} -.cockpit-orbital-reach-focused .cockpit-orbital-reach-name { - border-inline-start-color: var(--cockpit-accent, #00dcff); -} -.cockpit-orbital-reach-details { - margin: 3px 0 4px; - padding: 4px 6px; - border: 1px solid var(--orbital-faint); - background: rgba(0, 4, 8, 0.7); - user-select: text; -} -.cockpit-orbital-reach-where { - color: rgba(244, 251, 255, 0.66); - font-variant-numeric: tabular-nums; -} -.cockpit-orbital-readout .cockpit-orbital-reach-kicker { - margin-top: 4px; - color: var(--cockpit-accent, #00dcff); - font-weight: 700; - letter-spacing: 0.14em; -} -.cockpit-orbital-reach-tags { - display: grid; - gap: 2px; - margin: 3px 0 0; -} -.cockpit-orbital-reach-tag { - display: flex; - gap: 8px; - min-width: 0; -} -.cockpit-orbital-reach-tag dt { - flex: none; - color: rgba(244, 251, 255, 0.56); -} -.cockpit-orbital-reach-tag dd { - min-width: 0; - margin: 0; - color: #f4fbff; - overflow-wrap: anywhere; -} -.cockpit-orbital-reach-empty, -.cockpit-orbital-reach-more { - color: rgba(244, 251, 255, 0.56); -} -.cockpit-orbital-readout .cockpit-orbital-reach-more { - margin-top: 4px; -} diff --git a/src/ui/views/panels/CockpitOrbital.vue b/src/ui/views/panels/CockpitOrbital.vue index 51f14e1ed..47f19cbef 100644 --- a/src/ui/views/panels/CockpitOrbital.vue +++ b/src/ui/views/panels/CockpitOrbital.vue @@ -14,7 +14,6 @@ import { shownOwnerMark, } from '../../ownerFlag.js'; import { useCockpitStore } from '../../stores/cockpit.js'; -import CockpitOrbitalReach from './CockpitOrbitalReach.vue'; // The orbital view's HUD: a nadir reticle over the picture, the satellite's // identity and orbit, and the optical zoom. The Cockpit controller presents @@ -328,8 +327,6 @@ const lensStyle = computed(() => {

{{ focusText }}

- -
diff --git a/src/ui/views/panels/CockpitOrbitalReach.vue b/src/ui/views/panels/CockpitOrbitalReach.vue deleted file mode 100644 index 852799da9..000000000 --- a/src/ui/views/panels/CockpitOrbitalReach.vue +++ /dev/null @@ -1,109 +0,0 @@ - - - diff --git a/style.css b/style.css index 4d678058c..930cc2346 100644 --- a/style.css +++ b/style.css @@ -11,7 +11,7 @@ @import './src/ui/styles/cockpitBridge.css'; @import './src/ui/styles/cockpitRadar.css'; @import './src/ui/styles/cockpitOrbital.css'; -@import './src/ui/styles/cockpitOrbitalReach.css'; +@import './src/ui/styles/cockpitOrbitalFocus.css'; @import './src/ui/styles/overlays.css'; @import './src/ui/styles/layers.css'; @import './src/ui/styles/radio.css';