From dd02068ce914ef0201d5537c161cdd5cc198591d Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 23:59:53 +0100 Subject: [PATCH] feat(cockpit): list orbit's sites and satellites in Live Signals The orbital Context card is gone and the Contact card stays hidden in orbit again. Live Signals page 1 had nothing in orbit but TRACK ACQUIRED, because orbit feeds no Contacts snapshot; it now lists what is around the satellite, built the way the aircraft rows are, five rows with stable keys rebuilt once a second. Rows already shown keep their places while they still qualify, so a site passing under a low orbit is not pulled from under the pointer. Site rows come from the Mapped Installations layer (getNearby, asked only as far as the reach extends) and focus their site, or release it on the focused site's row, which reads as the subject does. A site the layer drops during its frequent reloads stays listed for 10 s, since sites do not move. With the layer off nothing is listed and a SITES STANDBY row says why. Satellite rows come from the satellites layer's bounded nearest query, measured from the satellite itself, and ride the satellite; RETURN goes back to the one before, skipping one that has left the catalogue. While the camera hops to a new satellite only TRACK ACQUIRED and RETURN show, so nothing is measured from the old position. An arrow pressed while a focus eases back takes the arrows' own clock. Site and satellite rows name their own actions. --- scripts/package-boundaries.json | 22 +- src/cockpitOrbitalFocus.js | 70 +- src/cockpitOrbitalFocus.test.mjs | 50 +- src/cockpitOrbitalSatellites.js | 55 ++ src/cockpitOrbitalSatellites.test.mjs | 28 + src/i18n/locales/ar.json | 18 +- src/i18n/locales/de.json | 18 +- src/i18n/locales/es.json | 18 +- src/i18n/locales/fr.json | 18 +- src/i18n/locales/it.json | 18 +- src/i18n/locales/ja.json | 18 +- src/i18n/locales/pt.json | 18 +- src/i18n/locales/ru.json | 18 +- src/i18n/locales/uk.json | 18 +- src/i18n/locales/zh-CN.json | 18 +- src/i18n/locales/zh-Hant.json | 18 +- src/i18n/same-as-english.json | 6 +- src/ui/applicationShell.js | 4 +- src/ui/cockpitController.js | 40 +- src/ui/cockpitCoordinator.js | 2 + src/ui/cockpitOrbitalFocus.js | 410 +++++---- src/ui/cockpitOrbitalFocus.test.mjs | 968 ++++++++++++-------- src/ui/cockpitOrbitalSatellites.js | 188 ++++ src/ui/cockpitOrbitalSignals.js | 260 ++++++ src/ui/cockpitOrbitalView.js | 11 +- src/ui/cockpitSignals.js | 13 +- src/ui/stores/cockpit.js | 18 - src/ui/styles/cockpitOrbitalFocus.css | 11 + src/ui/styles/cockpitOrbitalReach.css | 150 --- src/ui/views/panels/CockpitOrbital.vue | 3 - src/ui/views/panels/CockpitOrbitalReach.vue | 109 --- style.css | 2 +- 32 files changed, 1562 insertions(+), 1056 deletions(-) create mode 100644 src/cockpitOrbitalSatellites.js create mode 100644 src/cockpitOrbitalSatellites.test.mjs create mode 100644 src/ui/cockpitOrbitalSatellites.js create mode 100644 src/ui/cockpitOrbitalSignals.js create mode 100644 src/ui/styles/cockpitOrbitalFocus.css delete mode 100644 src/ui/styles/cockpitOrbitalReach.css delete mode 100644 src/ui/views/panels/CockpitOrbitalReach.vue 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';