mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 04:44:01 +00:00
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.
This commit is contained in:
1 parent
fad62a4ba8
commit
dd02068ce9
32 files changed
+1562
-1056
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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} كم",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} КМ",
|
||||
|
||||
@@ -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} КМ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+236
-174
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
+602
-366
File diff suppressed because it is too large.
Load diff
@@ -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<object>} 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);
|
||||
}
|
||||
@@ -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<object>} entries Nearest first.
|
||||
* @param {(entry: object) => string} keyOf
|
||||
* @param {Array<string>} 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;
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {
|
||||
<button id="cockpit-orbital-nadir" ref="nadirButton" type="button" class="cockpit-orbital-nadir" :aria-label="t('Return the view to nadir')" :title="t('Return to nadir (Home or 0)')" aria-keyshortcuts="Home 0">{{ t('NADIR') }}</button>
|
||||
</div>
|
||||
<p id="cockpit-orbital-focus" class="cockpit-orbital-focus" aria-live="polite">{{ focusText }}</p>
|
||||
<!-- The mapped points the view shows or can be turned to, each with FOCUS. -->
|
||||
<CockpitOrbitalReach />
|
||||
</div>
|
||||
<div class="cockpit-orbital-optics">
|
||||
<button id="cockpit-orbital-zoom-out" ref="zoomOutButton" type="button" class="cockpit-orbital-zoom-button" :disabled="atWidest" :aria-label="t('Zoom out')" :title="t('Zoom out (−)')" aria-keyshortcuts="- _">−</button>
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
|
||||
import { formatNumber, t } from '../../../i18n/index.js';
|
||||
import { formatCockpitCoordinate } from '../../cockpitPresentation.js';
|
||||
import { useCockpitStore } from '../../stores/cockpit.js';
|
||||
|
||||
// The mapped points the orbital view shows or can be turned to, under its
|
||||
// readout: ground points of the layers that are on, nearest to nadir first.
|
||||
// A row opens the point's details (its OpenStreetMap tags where the layer
|
||||
// keeps them); FOCUS keeps the camera on it, switches to it from another, or
|
||||
// ends the focus held on it. The Cockpit controller builds the list
|
||||
// (src/ui/cockpitOrbitalFocus.js) and keeps FOCUS's delegated listener;
|
||||
// which rows are open is this view's own.
|
||||
|
||||
const { orbitalReach } = storeToRefs(useCockpitStore());
|
||||
|
||||
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
|
||||
|
||||
const entries = computed(() => orbitalReach.value?.entries || []);
|
||||
const count = computed(() =>
|
||||
finite(orbitalReach.value?.count) ? orbitalReach.value.count : 0,
|
||||
);
|
||||
const open = ref(false);
|
||||
const selectedKey = ref(null);
|
||||
const root = useTemplateRef('root');
|
||||
|
||||
const toggleText = computed(() =>
|
||||
t('IN REACH · {count}', { count: formatNumber(count.value) }),
|
||||
);
|
||||
const moreText = computed(() =>
|
||||
count.value > entries.value.length
|
||||
? t('NEAREST {shown} OF {count}', {
|
||||
shown: formatNumber(entries.value.length),
|
||||
count: formatNumber(count.value),
|
||||
})
|
||||
: '',
|
||||
);
|
||||
|
||||
const angleText = (deg) =>
|
||||
finite(deg)
|
||||
? `${formatNumber(deg, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}°`
|
||||
: '—';
|
||||
const positionText = (entry) =>
|
||||
finite(entry.latitude) && finite(entry.longitude)
|
||||
? `${formatCockpitCoordinate(entry.latitude, 'N', 'S')} ${formatCockpitCoordinate(entry.longitude, 'E', 'W')}`
|
||||
: '—';
|
||||
|
||||
function toggleDetails(key) {
|
||||
selectedKey.value = selectedKey.value === key ? null : key;
|
||||
}
|
||||
|
||||
// A point that leaves the list closes its details. Keyboard focus inside
|
||||
// its row moves to the list's toggle, which stays, instead of the page.
|
||||
watch(
|
||||
entries,
|
||||
(list) => {
|
||||
const key = selectedKey.value;
|
||||
const active = document.activeElement;
|
||||
const row = active?.closest?.('[data-orbital-reach-key]');
|
||||
const leaving =
|
||||
row &&
|
||||
root.value?.contains(row) &&
|
||||
!list.some((entry) => entry.key === row.dataset.orbitalReachKey);
|
||||
if (key !== null && !list.some((entry) => entry.key === key))
|
||||
selectedKey.value = null;
|
||||
if (leaving)
|
||||
nextTick(() =>
|
||||
root.value
|
||||
?.querySelector('#cockpit-orbital-reach-toggle')
|
||||
?.focus({ preventScroll: true }),
|
||||
);
|
||||
},
|
||||
{ flush: 'pre' },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section ref="root" class="cockpit-orbital-reach" :aria-label="t('Mapped points in reach')">
|
||||
<button id="cockpit-orbital-reach-toggle" type="button" class="cockpit-orbital-reach-toggle" :aria-expanded="String(open)" aria-controls="cockpit-orbital-reach-body" :aria-label="open ? t('Hide the mapped points in reach') : t('Show the mapped points in reach')" :title="open ? t('Hide the mapped points in reach') : t('Show the mapped points in reach')" @click="open = !open">{{ toggleText }}</button>
|
||||
<div v-show="open" id="cockpit-orbital-reach-body" class="cockpit-orbital-reach-body">
|
||||
<p v-if="!entries.length" class="cockpit-orbital-reach-empty">{{ t('NOTHING MAPPED IN REACH') }}</p>
|
||||
<ul v-else class="cockpit-orbital-reach-list">
|
||||
<li v-for="entry in entries" :key="entry.key" class="cockpit-orbital-reach-item" :class="{ 'cockpit-orbital-reach-focused': entry.focused }" :data-orbital-reach-key="entry.key">
|
||||
<div class="cockpit-orbital-reach-row">
|
||||
<button type="button" class="cockpit-orbital-reach-name" :aria-expanded="String(selectedKey === entry.key)" :aria-controls="selectedKey === entry.key ? 'cockpit-orbital-reach-details' : undefined" @click="toggleDetails(entry.key)">
|
||||
<strong>{{ entry.label }}</strong>
|
||||
<small>{{ entry.layerName }}<template v-if="entry.layerName"> · </template>{{ angleText(entry.offNadirDeg) }}</small>
|
||||
</button>
|
||||
<button type="button" class="cockpit-orbital-reach-focus" :data-orbital-focus="entry.key" :aria-pressed="String(entry.focused)" :aria-disabled="entry.reachable ? undefined : 'true'" :aria-label="t('Keep the camera on {name}', { name: entry.label })" :title="entry.reachable ? t('Keep the camera on {name}', { name: entry.label }) : t('Beyond the off-nadir reach')">{{ t('FOCUS') }}</button>
|
||||
</div>
|
||||
<div v-if="selectedKey === entry.key" id="cockpit-orbital-reach-details" class="cockpit-orbital-reach-details">
|
||||
<p class="cockpit-orbital-reach-where">{{ positionText(entry) }}<template v-if="entry.source"> · {{ entry.source }}</template></p>
|
||||
<template v-if="entry.details.length">
|
||||
<p class="cockpit-orbital-reach-kicker">{{ entry.osm ? t('OPENSTREETMAP TAGS') : t('DETAILS') }}</p>
|
||||
<dl class="cockpit-orbital-reach-tags" translate="no">
|
||||
<div v-for="row in entry.details" :key="row.key" class="cockpit-orbital-reach-tag">
|
||||
<dt>{{ row.key }}</dt>
|
||||
<dd>{{ row.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="moreText" class="cockpit-orbital-reach-more">{{ moreText }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user