mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 04:44:01 +00:00
feat(cockpit): zoom the orbital view to the imaging sensor's swath
An imaging satellite with a known sensor can now zoom past 8x, up to 128x, as far as the view stays at least as wide as the sensor's narrowest standard swath at its live altitude; only swaths confirmed on a cited page open those steps. The top step follows the altitude and window shape and steps back when it falls below the zoom shown. The view draws the sensor's swath to scale along the ground track with a SENSOR SWATH readout (approximate off nadir), and a SENSOR button zooms to exactly that width when it lies in range at nadir. From 16x the imagery tiles the view will need 5 to 20 s ahead along the ground track are pre-loaded at the matching level. The mouse wheel steps the zoom one notch at a time, and the identity line shows how old the TLE is. The sensor figures come from a new bundled pack of 46 Earth-observation platforms, each value cited to its operator or agency, with its own data credit.
This commit is contained in:
1 parent
d0ef5250ac
commit
fe5470a852
39 files changed
+1985
-31
No files matched your search
@@ -230,6 +230,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
|
||||
| **World camera image subset** (2,230 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: ODOT TripCheck public images (ODOT permits use with credit) and MLIT site content under terms compatible with CC BY 4.0. Frames are not bundled or persisted; the server holds a bounded in-memory cache. | Per-source terms apply; NZTA Journey Planner web-cache rows are excluded; Vegagerðin rows are served by the Iceland pack instead | Oregon Department of Transportation (Camera courtesy of ODOT) and Japan Ministry of Land, Infrastructure, Transport and Tourism (出典:国土交通省 川の防災情報) |
|
||||
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
|
||||
| **UCS Satellite Database** UCS-Satellite-Database 5-1-2023 (3,564 records; operator, owner country, purpose) | `ucs_satellites/` | **Free, unrestricted use** (UCS database manual, section 3) | ✅ (acknowledgement requested) | "UCS Satellite Database (UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database" |
|
||||
| **Imaging sensor pack** (46 Earth-observation platforms: swath, resolution) | `imaging_sensors/` | Individual specification facts from operator and agency pages, cited per entry | ✅ (facts only) | "Imaging sensor swaths: operator and agency specifications" |
|
||||
| **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
| **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) |
|
||||
| **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) |
|
||||
@@ -299,6 +300,10 @@ The former bundled 2026-05-25 snapshot was removed 2026-07-16.
|
||||
|
||||
Operator, owner country and purpose for active satellites, keyed by NORAD number, from the Union of Concerned Scientists Satellite Database release **UCS-Satellite-Database 5-1-2023** (data current through 2023-05-01; https://www.ucsusa.org/satellite_database). The satellites layer joins it to CelesTrak positions and SATCAT records to file each satellite as a station, imaging, telecom, Starlink, probe or debris object, and to name its operator and owner. Section 3 of the UCS database manual: "The Database is free and its use is unrestricted. We request that its use be acknowledged and referenced in written materials. References should include the version of the Database that was used, which is indicated by the name of the Excel file, and a link to or URL for the webpage www.ucsusa.org/satellite_database." The app acknowledges it in the Data attribution popover. UCS has paused updates since this release; Starlink rows are dropped (Starlink is classed by name). Build: `scripts/build-ucs-satellites.py`; curation in the folder README.
|
||||
|
||||
### Imaging sensor pack (`imaging_sensors/`)
|
||||
|
||||
Swath widths and ground resolutions for 46 Earth-observation platforms (Landsat, Sentinel, MODIS, VIIRS, GOES, Himawari, Gaofen, CBERS, RADARSAT, TerraSAR-X, COSMO-SkyMed, ALOS, KOMPSAT, SPOT, Pléiades, WorldView, SkySat, PlanetScope, ICEYE, Capella, Umbra and others), compiled 2026-11-04 from the operator and agency pages cited per entry. The orbital view draws each satellite's sensor swath to scale and caps its zoom at that swath. Facts only; no text or images copied. A swath is confirmed on an operator, agency (ESA's eoPortal included) or academic page; resellers are not cited. The CEOS MIM Database (CC BY 4.0) is not yet used. Sources and unverified values: the folder README.
|
||||
|
||||
### Natural Earth physical regions (`natural_earth/`)
|
||||
|
||||
Curated from the **Natural Earth 10m physical vectors** (https://www.naturalearthdata.com/ —
|
||||
|
||||
@@ -895,6 +895,7 @@
|
||||
"src/ui/cockpitCamera.js",
|
||||
"src/ui/cockpitOrbitalView.js",
|
||||
"src/data/placeBelow.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/adminBoundaries.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
@@ -1044,6 +1045,7 @@
|
||||
"src/ui/cockpitCamera.js",
|
||||
"src/ui/cockpitOrbitalView.js",
|
||||
"src/data/placeBelow.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/adminBoundaries.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
@@ -1682,7 +1684,9 @@
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js"
|
||||
],
|
||||
@@ -3380,7 +3384,9 @@
|
||||
"src/layers/wildlifeSightings/photoText.js",
|
||||
"src/layers/wildlifeSightings/performance.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/data/ucsSatellites.js"
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -4035,7 +4041,9 @@
|
||||
"src/layers/wildlifeSightings/photoText.js",
|
||||
"src/layers/wildlifeSightings/performance.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/data/ucsSatellites.js"
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -4542,7 +4550,9 @@
|
||||
"modules": [
|
||||
"src/layers/satellites/source.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js"
|
||||
],
|
||||
@@ -5166,7 +5176,9 @@
|
||||
"src/sources/infrastructureData.js",
|
||||
"src/sources/eventPacks.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/data/ucsSatellites.js"
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js"
|
||||
],
|
||||
"external": ["@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
|
||||
+359
-5
@@ -13,10 +13,14 @@
|
||||
/** Mean Earth radius used for footprint and framing geometry, metres. */
|
||||
export const ORBITAL_EARTH_RADIUS_M = 6371008.8;
|
||||
|
||||
/** The optical zoom steps. */
|
||||
export const ORBITAL_ZOOM_LADDER = Object.freeze([1, 2, 4, 8]);
|
||||
/**
|
||||
* The whole zoom ladder. Steps past ORBITAL_BASE_MAX_ZOOM belong only to
|
||||
* imaging satellites with a known sensor, each up to the step its sensor's
|
||||
* swath allows (`orbitalMaxZoom`).
|
||||
*/
|
||||
export const ORBITAL_ZOOM_LADDER = Object.freeze([1, 2, 4, 8, 16, 32, 64, 128]);
|
||||
|
||||
/** The tightest step. */
|
||||
/** The tightest step for a satellite without sensor data. */
|
||||
export const ORBITAL_BASE_MAX_ZOOM = 8;
|
||||
|
||||
/** The zoom an orbital view opens at. */
|
||||
@@ -61,9 +65,9 @@ const ZOOM_EPSILON = 1e-6;
|
||||
|
||||
/**
|
||||
* The next tighter ladder step, up to `maxZoom`; at the cap the zoom stays.
|
||||
* A zoom between steps moves to the step above it.
|
||||
* A zoom between steps (the SENSOR step) moves to the step above it.
|
||||
* @param {number} zoom Current zoom.
|
||||
* @param {number} [maxZoom] The tightest step.
|
||||
* @param {number} [maxZoom] The satellite's tightest step.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function nextOrbitalZoom(zoom, maxZoom = ORBITAL_BASE_MAX_ZOOM) {
|
||||
@@ -88,6 +92,86 @@ export function previousOrbitalZoom(zoom) {
|
||||
return wider.length ? wider[wider.length - 1] : ORBITAL_DEFAULT_ZOOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ground width the view spans at a zoom: what the SWATH readout shows.
|
||||
* @param {number} altitudeM Satellite altitude, metres.
|
||||
* @param {number} zoom Magnification.
|
||||
* @param {number} aspect Canvas width / height.
|
||||
* @returns {number|null} Kilometres, or null for an unusable altitude.
|
||||
*/
|
||||
export function viewWidthKm(altitudeM, zoom, aspect) {
|
||||
return footprintWidthKm(
|
||||
altitudeM,
|
||||
fovForZoom(orbitalBaseFovRad(altitudeM), zoom),
|
||||
aspect,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A satellite's tightest zoom step at its live altitude: the tightest ladder
|
||||
* step whose view is still at least as wide as its sensor's narrowest
|
||||
* standard swath, so the view never shows finer ground than the sensor
|
||||
* images. Never below ORBITAL_BASE_MAX_ZOOM; no sensor swath, no extra steps.
|
||||
* @param {number} altitudeM Live altitude, metres.
|
||||
* @param {number|null|undefined} sensorSwathKm Narrowest standard swath.
|
||||
* @param {number} aspect Canvas width / height.
|
||||
* @returns {number} One of ORBITAL_ZOOM_LADDER.
|
||||
*/
|
||||
export function orbitalMaxZoom(altitudeM, sensorSwathKm, aspect) {
|
||||
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0)
|
||||
return ORBITAL_BASE_MAX_ZOOM;
|
||||
let max = ORBITAL_BASE_MAX_ZOOM;
|
||||
for (const step of ORBITAL_ZOOM_LADDER) {
|
||||
if (step <= ORBITAL_BASE_MAX_ZOOM) continue;
|
||||
const width = viewWidthKm(altitudeM, step, aspect);
|
||||
if (!Number.isFinite(width)) break;
|
||||
if (width * (1 + ZOOM_EPSILON) < sensorSwathKm) break;
|
||||
max = step;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* The zoom at which the view is exactly as wide as the sensor's swath, for
|
||||
* the SENSOR step; null when that lies outside 1x..`maxZoom` (a swath wider
|
||||
* than the 1x view, or narrower than the tightest allowed step).
|
||||
* @param {number} altitudeM Live altitude, metres.
|
||||
* @param {number|null|undefined} sensorSwathKm
|
||||
* @param {number} aspect Canvas width / height.
|
||||
* @param {number} maxZoom The satellite's tightest step.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function sensorSwathZoom(altitudeM, sensorSwathKm, aspect, maxZoom) {
|
||||
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0) return null;
|
||||
const widest = viewWidthKm(altitudeM, 1, aspect);
|
||||
const tightest = viewWidthKm(altitudeM, maxZoom, aspect);
|
||||
if (!Number.isFinite(widest) || !Number.isFinite(tightest)) return null;
|
||||
if (sensorSwathKm > widest * (1 + ZOOM_EPSILON)) return null;
|
||||
if (sensorSwathKm < tightest * (1 - ZOOM_EPSILON)) return null;
|
||||
// The width falls monotonically with zoom: bisect in log space.
|
||||
let low = 0;
|
||||
let high = Math.log2(maxZoom);
|
||||
for (let i = 0; i < 48; i += 1) {
|
||||
const mid = (low + high) / 2;
|
||||
if (viewWidthKm(altitudeM, 2 ** mid, aspect) > sensorSwathKm) low = mid;
|
||||
else high = mid;
|
||||
}
|
||||
return Math.round(2 ** ((low + high) / 2) * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the view's width a sensor swath covers, for the strip drawn
|
||||
* to scale in the reticle: 0..1, 1 when the swath fills the view or more.
|
||||
* @param {number|null|undefined} sensorSwathKm
|
||||
* @param {number|null|undefined} viewKm The view's current ground width.
|
||||
* @returns {number|null} Null without both widths.
|
||||
*/
|
||||
export function sensorStripFraction(sensorSwathKm, viewKm) {
|
||||
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0) return null;
|
||||
if (!Number.isFinite(viewKm) || viewKm <= 0) return null;
|
||||
return Math.min(1, sensorSwathKm / viewKm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Angular radius of the Earth's disk seen from an altitude, radians.
|
||||
* @param {number} altitudeM Height above the mean sphere, metres.
|
||||
@@ -411,3 +495,273 @@ export function offNadirOrientation(nadir, { northDeg = 0, eastDeg = 0 } = {}) {
|
||||
const direction = combine(d1, Math.cos(e), r, Math.sin(e));
|
||||
return { direction, up: u1, right: cross(direction, u1) };
|
||||
}
|
||||
|
||||
/** Scroll distance, in pixels, that makes one zoom step. A mouse notch is ~100. */
|
||||
export const ORBITAL_WHEEL_PIXELS_PER_STEP = 100;
|
||||
|
||||
/**
|
||||
* After a step, the rest of that gesture is absorbed for this long, so one
|
||||
* physical notch (which some browsers deliver as several events) is one step.
|
||||
*/
|
||||
export const ORBITAL_WHEEL_COOLDOWN_MS = 200;
|
||||
|
||||
/**
|
||||
* A pause this long ends a scroll gesture: a partial sum left from an earlier
|
||||
* gesture never adds to a later one.
|
||||
*/
|
||||
export const ORBITAL_WHEEL_GESTURE_GAP_MS = 300;
|
||||
|
||||
/** `WheelEvent.deltaMode` line and page units, in pixels. */
|
||||
const WHEEL_LINE_PX = 16;
|
||||
const WHEEL_PAGE_PX = 800;
|
||||
|
||||
/**
|
||||
* Turn wheel events into zoom steps. Trackpads send many small pixel deltas,
|
||||
* so they are summed until they reach a step; a change of direction, or a
|
||||
* pause of ORBITAL_WHEEL_GESTURE_GAP_MS, starts over. Wheeling away from the
|
||||
* viewer (negative deltaY) zooms in.
|
||||
* @param {{sum: number, lastStepMs: number, lastEventMs?: number}|null}
|
||||
* accumulator Previous state.
|
||||
* @param {{deltaY: number, deltaMode?: number}} wheel The event's deltas.
|
||||
* @param {number} nowMs
|
||||
* @returns {{accumulator: {sum: number, lastStepMs: number,
|
||||
* lastEventMs: number}, step: -1|0|1}} The new state and the step to take
|
||||
* (1 = zoom in).
|
||||
*/
|
||||
export function wheelZoomStep(accumulator, wheel, nowMs) {
|
||||
const held = accumulator || { sum: 0, lastStepMs: -Infinity };
|
||||
const previous =
|
||||
nowMs - (held.lastEventMs ?? -Infinity) > ORBITAL_WHEEL_GESTURE_GAP_MS
|
||||
? { ...held, sum: 0 }
|
||||
: held;
|
||||
const unit =
|
||||
wheel?.deltaMode === 1
|
||||
? WHEEL_LINE_PX
|
||||
: wheel?.deltaMode === 2
|
||||
? WHEEL_PAGE_PX
|
||||
: 1;
|
||||
const delta = (Number.isFinite(wheel?.deltaY) ? wheel.deltaY : 0) * unit;
|
||||
if (!delta) return { accumulator: held, step: 0 };
|
||||
if (nowMs - previous.lastStepMs < ORBITAL_WHEEL_COOLDOWN_MS)
|
||||
return {
|
||||
accumulator: { ...previous, sum: 0, lastEventMs: nowMs },
|
||||
step: 0,
|
||||
};
|
||||
const sum =
|
||||
Math.sign(previous.sum) === -Math.sign(delta)
|
||||
? delta
|
||||
: previous.sum + delta;
|
||||
if (Math.abs(sum) < ORBITAL_WHEEL_PIXELS_PER_STEP)
|
||||
return { accumulator: { ...previous, sum, lastEventMs: nowMs }, step: 0 };
|
||||
return {
|
||||
accumulator: { sum: 0, lastStepMs: nowMs, lastEventMs: nowMs },
|
||||
step: sum < 0 ? 1 : -1,
|
||||
};
|
||||
}
|
||||
|
||||
/** From this zoom the view pre-loads imagery ahead along the ground track. */
|
||||
export const ORBITAL_PREFETCH_MIN_ZOOM = 16;
|
||||
|
||||
/** How often the pre-load looks ahead, ms. */
|
||||
export const ORBITAL_PREFETCH_INTERVAL_MS = 2000;
|
||||
|
||||
/** How far ahead it looks, seconds of ground-track travel, nearest first. */
|
||||
export const ORBITAL_PREFETCH_LEAD_S = Object.freeze([5, 10, 15, 20]);
|
||||
|
||||
/**
|
||||
* Where across the track it looks, as fractions of the view's width: the
|
||||
* centre line and a third out each side, so a tile row spans the frame.
|
||||
*/
|
||||
export const ORBITAL_PREFETCH_ACROSS = Object.freeze([0, -1 / 3, 1 / 3]);
|
||||
|
||||
/** At most this many tile requests per look-ahead. */
|
||||
export const ORBITAL_PREFETCH_MAX_TILES = 12;
|
||||
|
||||
const toRadians = (deg) => (deg * Math.PI) / 180;
|
||||
const toDegrees = (rad) => (rad * 180) / Math.PI;
|
||||
|
||||
/**
|
||||
* The point `distanceM` from a point along an initial bearing, on a sphere.
|
||||
* @returns {{lat: number, lon: number}} Degrees, longitude in -180..180.
|
||||
*/
|
||||
export function destinationPoint(latDeg, lonDeg, bearingDeg, distanceM) {
|
||||
const delta = distanceM / ORBITAL_EARTH_RADIUS_M;
|
||||
const lat1 = toRadians(latDeg);
|
||||
const theta = toRadians(bearingDeg);
|
||||
const lat2 = Math.asin(
|
||||
Math.sin(lat1) * Math.cos(delta) +
|
||||
Math.cos(lat1) * Math.sin(delta) * Math.cos(theta),
|
||||
);
|
||||
const lon2 =
|
||||
toRadians(lonDeg) +
|
||||
Math.atan2(
|
||||
Math.sin(theta) * Math.sin(delta) * Math.cos(lat1),
|
||||
Math.cos(delta) - Math.sin(lat1) * Math.sin(lat2),
|
||||
);
|
||||
const lon = ((((toDegrees(lon2) + 180) % 360) + 360) % 360) - 180;
|
||||
return { lat: toDegrees(lat2), lon };
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the view will pass over: for each lead time, the sub-satellite
|
||||
* point that far along the ground track (orbital speed scaled down to the
|
||||
* ground), and points across the track at `across` fractions of the view's
|
||||
* width. Nearest lead first. Empty without a track, speed or width.
|
||||
* @param {{latitude: number, longitude: number, groundTrackDeg: number,
|
||||
* velocityMps: number, altitudeM: number}} info Satellite subject.
|
||||
* @param {number} viewKm The view's ground width, km.
|
||||
* @returns {Array<{lat: number, lon: number}>}
|
||||
*/
|
||||
export function groundTrackAhead(
|
||||
info,
|
||||
viewKm,
|
||||
{ leadS = ORBITAL_PREFETCH_LEAD_S, across = ORBITAL_PREFETCH_ACROSS } = {},
|
||||
) {
|
||||
const { latitude, longitude, groundTrackDeg, velocityMps, altitudeM } =
|
||||
info || {};
|
||||
if (
|
||||
![latitude, longitude, groundTrackDeg, velocityMps, altitudeM].every(
|
||||
Number.isFinite,
|
||||
) ||
|
||||
velocityMps <= 0 ||
|
||||
altitudeM <= 0 ||
|
||||
!(viewKm > 0)
|
||||
)
|
||||
return [];
|
||||
const R = ORBITAL_EARTH_RADIUS_M;
|
||||
const groundMps = (velocityMps * R) / (R + altitudeM);
|
||||
const points = [];
|
||||
for (const seconds of leadS) {
|
||||
const centre = destinationPoint(
|
||||
latitude,
|
||||
longitude,
|
||||
groundTrackDeg,
|
||||
groundMps * seconds,
|
||||
);
|
||||
for (const fraction of across) {
|
||||
points.push(
|
||||
fraction === 0
|
||||
? centre
|
||||
: destinationPoint(
|
||||
centre.lat,
|
||||
centre.lon,
|
||||
groundTrackDeg + (fraction > 0 ? 90 : -90),
|
||||
Math.abs(fraction) * viewKm * 1000,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** WGS84's equatorial radius: Cesium sizes terrain and imagery tiles on it. */
|
||||
const WGS84_MAXIMUM_RADIUS_M = 6378137;
|
||||
|
||||
/**
|
||||
* Cesium's geometric error for a heightmap terrain tile at `level`, metres:
|
||||
* its level-zero estimate (TerrainProvider's
|
||||
* getEstimatedLevelZeroGeometricErrorForAHeightmap, heightmap quality 0.25)
|
||||
* for the ellipsoid terrain's 64-sample tiles on two root tiles, halved per
|
||||
* level.
|
||||
* @param {number} level
|
||||
* @returns {number}
|
||||
*/
|
||||
export function ellipsoidTerrainLevelErrorM(level) {
|
||||
return (WGS84_MAXIMUM_RADIUS_M * 2 * Math.PI * 0.25) / (64 * 2) / 2 ** level;
|
||||
}
|
||||
|
||||
/** The deepest terrain level considered. */
|
||||
const MAX_TERRAIN_LEVEL = 30;
|
||||
|
||||
/**
|
||||
* The imagery level Cesium draws under the view, and so the one to pre-load.
|
||||
* Cesium refines the terrain tile under the view while its geometric error
|
||||
* spans more than `maximumScreenSpaceError` pixels (QuadtreePrimitive's
|
||||
* screenSpaceError: error over the ground a pixel covers), then drapes the
|
||||
* imagery level whose texel spacing is nearest that error (ImageryLayer's
|
||||
* getLevelWithMaximumTexelSpacing, rounded): about one to two screen pixels
|
||||
* per texel, not one. Fog, which below 800 km can only coarsen the choice,
|
||||
* is left out. Clamped to the provider's levels; null without a usable input.
|
||||
* @param {{getNumberOfXTilesAtLevel?: (level: number) => number,
|
||||
* rectangle?: {width: number}}} tilingScheme The imagery tiling scheme.
|
||||
* @param {{metresPerPixel: number, latitudeDeg: number,
|
||||
* geographic?: boolean, tileWidthPx?: number, minimumLevel?: number,
|
||||
* maximumLevel?: number, maximumScreenSpaceError?: number,
|
||||
* terrainLevelErrorM?: (level: number) => number}} view `metresPerPixel`
|
||||
* is the ground one CSS pixel covers at the view's centre; `geographic`
|
||||
* marks a scheme in the geographic projection, whose texels do not narrow
|
||||
* with latitude as Web Mercator's do; `terrainLevelErrorM` is the terrain
|
||||
* provider's getLevelMaximumGeometricError.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function imageryLevelForView(
|
||||
tilingScheme,
|
||||
{
|
||||
metresPerPixel,
|
||||
latitudeDeg,
|
||||
geographic = false,
|
||||
tileWidthPx = 256,
|
||||
minimumLevel = 0,
|
||||
maximumLevel = 30,
|
||||
maximumScreenSpaceError = 2,
|
||||
terrainLevelErrorM = ellipsoidTerrainLevelErrorM,
|
||||
},
|
||||
) {
|
||||
if (!(metresPerPixel > 0) || !Number.isFinite(latitudeDeg)) return null;
|
||||
const sse = maximumScreenSpaceError > 0 ? maximumScreenSpaceError : 2;
|
||||
let terrainLevel = 0;
|
||||
while (
|
||||
terrainLevel < MAX_TERRAIN_LEVEL &&
|
||||
!(terrainLevelErrorM(terrainLevel) / metresPerPixel <= sse)
|
||||
)
|
||||
terrainLevel += 1;
|
||||
const errorM = terrainLevelErrorM(terrainLevel);
|
||||
if (!(errorM > 0)) return null;
|
||||
const rootTiles = tilingScheme?.getNumberOfXTilesAtLevel?.(0) || 1;
|
||||
const widthRad = tilingScheme?.rectangle?.width || 2 * Math.PI;
|
||||
const latitudeFactor = geographic
|
||||
? 1
|
||||
: Math.cos(toRadians(Math.min(89, Math.abs(latitudeDeg))));
|
||||
const levelZeroTexelM =
|
||||
(WGS84_MAXIMUM_RADIUS_M * widthRad * latitudeFactor) /
|
||||
((tileWidthPx || 256) * rootTiles);
|
||||
const level = Math.max(0, Math.round(Math.log2(levelZeroTexelM / errorM)));
|
||||
if (!Number.isFinite(level)) return null;
|
||||
const top = Number.isFinite(maximumLevel) ? maximumLevel : 30;
|
||||
const bottom = Number.isFinite(minimumLevel) ? minimumLevel : 0;
|
||||
return Math.min(top, Math.max(bottom, level));
|
||||
}
|
||||
|
||||
/**
|
||||
* The tiles at `level` holding `points`, in order, without repeats or tiles
|
||||
* `skip` reports as already asked for, at most `maxTiles`.
|
||||
* @param {{positionToTileXY: Function}} tilingScheme Cesium tiling scheme.
|
||||
* @param {Array<{lat: number, lon: number}>} points Degrees.
|
||||
* @param {number} level
|
||||
* @param {{maxTiles?: number, skip?: (key: string) => boolean}} [options]
|
||||
* @returns {Array<{x: number, y: number, level: number, key: string}>}
|
||||
*/
|
||||
export function tilesForPoints(
|
||||
tilingScheme,
|
||||
points,
|
||||
level,
|
||||
{ maxTiles = ORBITAL_PREFETCH_MAX_TILES, skip = () => false } = {},
|
||||
) {
|
||||
const tiles = [];
|
||||
if (typeof tilingScheme?.positionToTileXY !== 'function') return tiles;
|
||||
if (!Number.isInteger(level) || level < 0) return tiles;
|
||||
const seen = new Set();
|
||||
for (const point of points) {
|
||||
if (tiles.length >= maxTiles) break;
|
||||
const xy = tilingScheme.positionToTileXY(
|
||||
{ longitude: toRadians(point.lon), latitude: toRadians(point.lat) },
|
||||
level,
|
||||
);
|
||||
if (!xy) continue;
|
||||
const key = `${level}/${xy.x}/${xy.y}`;
|
||||
if (seen.has(key) || skip(key)) continue;
|
||||
seen.add(key);
|
||||
tiles.push({ x: xy.x, y: xy.y, level, key });
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ORBITAL_BASE_FOV_LEO_RAD,
|
||||
ORBITAL_EARTH_RADIUS_M,
|
||||
ORBITAL_SETTLE_MAX_MS,
|
||||
ORBITAL_WHEEL_COOLDOWN_MS,
|
||||
cesiumFrustumFovRad,
|
||||
earthAngularRadiusRad,
|
||||
footprintWidthKm,
|
||||
@@ -14,6 +15,12 @@ import {
|
||||
nextOrbitalZoom,
|
||||
offNadirAngleDeg,
|
||||
offNadirSwathScale,
|
||||
orbitalMaxZoom,
|
||||
sensorStripFraction,
|
||||
sensorSwathZoom,
|
||||
viewWidthKm,
|
||||
wheelZoomStep,
|
||||
ORBITAL_WHEEL_GESTURE_GAP_MS,
|
||||
offNadirOrientation,
|
||||
orbitalBaseFovRad,
|
||||
previousOrbitalZoom,
|
||||
@@ -21,7 +28,16 @@ import {
|
||||
zoomTransitionBlurPx,
|
||||
zoomTransitionProgress,
|
||||
zoomTransitionState,
|
||||
ORBITAL_PREFETCH_ACROSS,
|
||||
ORBITAL_PREFETCH_LEAD_S,
|
||||
ORBITAL_PREFETCH_MAX_TILES,
|
||||
destinationPoint,
|
||||
groundTrackAhead,
|
||||
imageryLevelForView,
|
||||
tilesForPoints,
|
||||
} from './cockpitOrbital.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { greatCircleKm } from './geoDistance.js';
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
@@ -257,6 +273,97 @@ test('a landed zoom step holds for its imagery, but never past the cap', () => {
|
||||
assert.equal(zoomSettleHolding(Number.NaN, false), true);
|
||||
});
|
||||
|
||||
const WIDE = 16 / 9;
|
||||
|
||||
test("the zoom ladder climbs to a satellite's own cap and steps off a SENSOR zoom", () => {
|
||||
assert.equal(nextOrbitalZoom(8), 8, 'no sensor: 8x is the top');
|
||||
assert.equal(nextOrbitalZoom(8, 64), 16);
|
||||
assert.equal(nextOrbitalZoom(32, 64), 64);
|
||||
assert.equal(nextOrbitalZoom(64, 64), 64, 'the cap holds');
|
||||
assert.equal(nextOrbitalZoom(5.7, 64), 8, 'between steps: the step above');
|
||||
assert.equal(previousOrbitalZoom(5.7), 4, 'between steps: the step below');
|
||||
assert.equal(previousOrbitalZoom(128), 64);
|
||||
assert.equal(previousOrbitalZoom(1), 1);
|
||||
});
|
||||
|
||||
test('a sensor caps the zoom at its swath for the live altitude', () => {
|
||||
// WorldView-3 at 617 km, 13.1 km swath: 64x spans ~20 km, 128x ~10 km.
|
||||
assert.ok(viewWidthKm(617e3, 64, WIDE) >= 13.1);
|
||||
assert.ok(viewWidthKm(617e3, 128, WIDE) < 13.1);
|
||||
assert.equal(orbitalMaxZoom(617e3, 13.1, WIDE), 64);
|
||||
// ICEYE spotlight, 5 km at 570 km.
|
||||
assert.equal(orbitalMaxZoom(570e3, 5, WIDE), 128);
|
||||
// PlanetScope SuperDove, 32.5 km: 32x at 525 km but 16x at 470 km.
|
||||
assert.equal(orbitalMaxZoom(525e3, 32.5, WIDE), 32);
|
||||
assert.equal(orbitalMaxZoom(470e3, 32.5, WIDE), 16);
|
||||
// Landsat's 185 km swath fits inside 8x: no extra steps, never fewer.
|
||||
assert.equal(orbitalMaxZoom(705e3, 185, WIDE), 8);
|
||||
assert.equal(orbitalMaxZoom(705e3, 2330, WIDE), 8);
|
||||
// No sensor, or an unusable swath.
|
||||
assert.equal(orbitalMaxZoom(617e3, null, WIDE), 8);
|
||||
assert.equal(orbitalMaxZoom(617e3, 0, WIDE), 8);
|
||||
// A swath exactly as wide as the 16x view allows 16x.
|
||||
assert.equal(orbitalMaxZoom(617e3, viewWidthKm(617e3, 16, WIDE), WIDE), 16);
|
||||
});
|
||||
|
||||
test('the SENSOR step frames the swath when it lies in range', () => {
|
||||
const landsat = sensorSwathZoom(705e3, 185, WIDE, 8);
|
||||
assert.ok(landsat > 4 && landsat < 8, String(landsat));
|
||||
assert.ok(Math.abs(viewWidthKm(705e3, landsat, WIDE) - 185) < 1);
|
||||
const worldview = sensorSwathZoom(617e3, 13.1, WIDE, 64);
|
||||
assert.equal(worldview, null, 'narrower than the 64x cap allows');
|
||||
assert.equal(sensorSwathZoom(705e3, 2330, WIDE, 8), null, 'wider than 1x');
|
||||
assert.equal(sensorSwathZoom(705e3, null, WIDE, 8), null);
|
||||
});
|
||||
|
||||
test('the sensor strip spans its share of the view', () => {
|
||||
assert.equal(sensorStripFraction(13, 130), 0.1);
|
||||
assert.equal(sensorStripFraction(500, 131), 1, 'clipped to the view');
|
||||
assert.equal(sensorStripFraction(null, 131), null);
|
||||
assert.equal(sensorStripFraction(13.1, null), null);
|
||||
});
|
||||
|
||||
test('the wheel steps once per notch and sums a trackpad swipe', () => {
|
||||
// A mouse notch away from the viewer zooms in.
|
||||
let r = wheelZoomStep(null, { deltaY: -100, deltaMode: 0 }, 1000);
|
||||
assert.equal(r.step, 1);
|
||||
// The rest of that notch, inside the cooldown, is absorbed.
|
||||
r = wheelZoomStep(r.accumulator, { deltaY: -100, deltaMode: 0 }, 1050);
|
||||
assert.equal(r.step, 0);
|
||||
// A notch toward the viewer after the cooldown zooms out.
|
||||
r = wheelZoomStep(
|
||||
r.accumulator,
|
||||
{ deltaY: 120, deltaMode: 0 },
|
||||
1050 + ORBITAL_WHEEL_COOLDOWN_MS + 1,
|
||||
);
|
||||
assert.equal(r.step, -1);
|
||||
// A trackpad's small deltas add up before they step.
|
||||
let pad = null;
|
||||
const steps = [];
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const out = wheelZoomStep(
|
||||
pad,
|
||||
{ deltaY: -30, deltaMode: 0 },
|
||||
5000 + i * 16,
|
||||
);
|
||||
pad = out.accumulator;
|
||||
steps.push(out.step);
|
||||
}
|
||||
assert.deepEqual(steps, [0, 0, 0, 1]);
|
||||
// Turning back starts the sum over.
|
||||
const turned = wheelZoomStep(
|
||||
{ sum: -90, lastStepMs: -Infinity },
|
||||
{ deltaY: 30, deltaMode: 0 },
|
||||
9000,
|
||||
);
|
||||
assert.equal(turned.step, 0);
|
||||
assert.equal(turned.accumulator.sum, 30);
|
||||
// Line-mode wheels (Firefox): three lines make a notch.
|
||||
assert.equal(wheelZoomStep(null, { deltaY: -3, deltaMode: 1 }, 0).step, 0);
|
||||
assert.equal(wheelZoomStep(null, { deltaY: -7, deltaMode: 1 }, 0).step, 1);
|
||||
assert.equal(wheelZoomStep(null, { deltaY: 0 }, 0).step, 0);
|
||||
});
|
||||
|
||||
test('a heading turns the nadir frame, keeping north to fall back on', () => {
|
||||
// Over the equator at 0°E: north is +z, east is +y.
|
||||
const position = { x: ORBITAL_EARTH_RADIUS_M + 500_000, y: 0, z: 0 };
|
||||
@@ -274,6 +381,26 @@ test('a heading turns the nadir frame, keeping north to fall back on', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('a partial scroll does not carry over into a later gesture', () => {
|
||||
// 90 px: short of a step.
|
||||
let r = wheelZoomStep(null, { deltaY: -90, deltaMode: 0 }, 1000);
|
||||
assert.equal(r.step, 0);
|
||||
// Still the same gesture: 10 px more completes the step.
|
||||
const same = wheelZoomStep(
|
||||
r.accumulator,
|
||||
{ deltaY: -10, deltaMode: 0 },
|
||||
1100,
|
||||
);
|
||||
assert.equal(same.step, 1);
|
||||
// Minutes later a 10 px nudge is a new gesture, not the rest of the old.
|
||||
r = wheelZoomStep(
|
||||
r.accumulator,
|
||||
{ deltaY: -10, deltaMode: 0 },
|
||||
1000 + ORBITAL_WHEEL_GESTURE_GAP_MS + 60_000,
|
||||
);
|
||||
assert.equal(r.step, 0);
|
||||
});
|
||||
|
||||
test('the total off-nadir angle combines both tilts', () => {
|
||||
assert.equal(offNadirAngleDeg(), 0);
|
||||
assert.ok(Math.abs(offNadirAngleDeg({ northDeg: 10 }) - 10) < 1e-9);
|
||||
@@ -300,3 +427,148 @@ test("a tilted view's width grows faster across the frame than along it", () =>
|
||||
}
|
||||
assert.equal(offNadirSwathScale({ northDeg: Number.NaN }), 1);
|
||||
});
|
||||
|
||||
// ── imagery pre-load ahead of the ground track ─────────────────────────
|
||||
|
||||
const PASS = Object.freeze({
|
||||
latitude: 30,
|
||||
longitude: -97,
|
||||
groundTrackDeg: 45,
|
||||
velocityMps: 7_600,
|
||||
altitudeM: 500_000,
|
||||
});
|
||||
|
||||
test('destinationPoint walks a great circle along a bearing', () => {
|
||||
const east = destinationPoint(0, 179.9, 90, 111_195 * 0.2);
|
||||
assert.ok(Math.abs(east.lat) < 1e-9);
|
||||
assert.ok(Math.abs(east.lon - -179.9) < 1e-3, 'wraps the antimeridian');
|
||||
const north = destinationPoint(10, 20, 0, 50_000);
|
||||
assert.ok(Math.abs(greatCircleKm({ lat: 10, lon: 20 }, north) - 50) < 0.1);
|
||||
assert.ok(north.lat > 10 && Math.abs(north.lon - 20) < 1e-9);
|
||||
});
|
||||
|
||||
test('the points ahead run along the ground track at ground speed', () => {
|
||||
const points = groundTrackAhead(PASS, 20);
|
||||
const across = ORBITAL_PREFETCH_ACROSS.length;
|
||||
assert.equal(points.length, ORBITAL_PREFETCH_LEAD_S.length * across);
|
||||
// Ground speed is the orbital speed scaled to the surface: ~7.0 km/s.
|
||||
const groundKmPerS = (7.6 * 6371) / (6371 + 500);
|
||||
const start = { lat: PASS.latitude, lon: PASS.longitude };
|
||||
ORBITAL_PREFETCH_LEAD_S.forEach((seconds, i) => {
|
||||
const centre = points[i * across];
|
||||
const km = greatCircleKm(start, centre);
|
||||
assert.ok(Math.abs(km - groundKmPerS * seconds) < 0.5, `${seconds} s`);
|
||||
// North-east of the start, on a 45° track.
|
||||
assert.ok(centre.lat > PASS.latitude && centre.lon > PASS.longitude);
|
||||
// The side points sit a third of the view off the centre line.
|
||||
for (const side of points.slice(i * across + 1, (i + 1) * across))
|
||||
assert.ok(Math.abs(greatCircleKm(centre, side) - 20 / 3) < 0.05);
|
||||
});
|
||||
assert.deepEqual(groundTrackAhead({ ...PASS, groundTrackDeg: null }, 20), []);
|
||||
assert.deepEqual(groundTrackAhead(PASS, null), []);
|
||||
});
|
||||
|
||||
test("the tile level is the one Cesium draws for the view", () => {
|
||||
const mercator = new Cesium.WebMercatorTilingScheme();
|
||||
// Worked case: 10 m per CSS pixel at the equator. Cesium refines the
|
||||
// ellipsoid terrain until a tile's error is at most 2 px, 20 m: level 12,
|
||||
// 78,271.5 m / 2^12 = 19.11 m. The Web Mercator texel nearest that is
|
||||
// level 13's (156,543 m / 2^13 = 19.11 m), about 1.9 px each, where a
|
||||
// one-pixel texel would have asked for level 14.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 10, latitudeDeg: 0 }),
|
||||
13,
|
||||
);
|
||||
// 5 m/px: terrain level 13 (9.55 m), imagery level 14.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 5, latitudeDeg: 0 }),
|
||||
14,
|
||||
);
|
||||
// 7 m/px still takes terrain level 13 (9.55 m <= 14 m): level 14, so a
|
||||
// texel spans 1.4 px.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 7, latitudeDeg: 0 }),
|
||||
14,
|
||||
);
|
||||
// At 60° a Mercator texel covers half the ground: one level coarser.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 10, latitudeDeg: 60 }),
|
||||
12,
|
||||
);
|
||||
// A geographic scheme starts from two root tiles and keeps its texels at
|
||||
// every latitude.
|
||||
const geographic = new Cesium.GeographicTilingScheme();
|
||||
for (const latitudeDeg of [0, 60])
|
||||
assert.equal(
|
||||
imageryLevelForView(geographic, {
|
||||
metresPerPixel: 10,
|
||||
latitudeDeg,
|
||||
geographic: true,
|
||||
}),
|
||||
12,
|
||||
`${latitudeDeg}°`,
|
||||
);
|
||||
// A terrain provider's own errors (Cesium World Terrain: 65-sample tiles)
|
||||
// and a looser screen-space error are honoured.
|
||||
const worldTerrain = (level) =>
|
||||
(6378137 * 2 * Math.PI * 0.25) / (65 * 2) / 2 ** level;
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, {
|
||||
metresPerPixel: 10,
|
||||
latitudeDeg: 0,
|
||||
terrainLevelErrorM: worldTerrain,
|
||||
}),
|
||||
13,
|
||||
);
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, {
|
||||
metresPerPixel: 10,
|
||||
latitudeDeg: 0,
|
||||
maximumScreenSpaceError: 4,
|
||||
}),
|
||||
12,
|
||||
);
|
||||
// Clamped to the provider's levels.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, {
|
||||
metresPerPixel: 5,
|
||||
latitudeDeg: 0,
|
||||
maximumLevel: 12,
|
||||
}),
|
||||
12,
|
||||
);
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 0, latitudeDeg: 0 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('the tiles ahead are the ones the points fall in, bounded and new', () => {
|
||||
const mercator = new Cesium.WebMercatorTilingScheme();
|
||||
const points = groundTrackAhead(PASS, 20);
|
||||
const tiles = tilesForPoints(mercator, points, 13);
|
||||
assert.ok(tiles.length > 0 && tiles.length <= ORBITAL_PREFETCH_MAX_TILES);
|
||||
assert.equal(new Set(tiles.map((tile) => tile.key)).size, tiles.length);
|
||||
// Each tile holds its point: the first is the centre line 5 s ahead.
|
||||
const first = mercator.tileXYToRectangle(tiles[0].x, tiles[0].y, 13);
|
||||
assert.ok(
|
||||
Cesium.Rectangle.contains(
|
||||
first,
|
||||
Cesium.Cartographic.fromDegrees(points[0].lon, points[0].lat),
|
||||
),
|
||||
);
|
||||
// Ahead along a north-east track: rows north (smaller y), columns east.
|
||||
const here = mercator.positionToTileXY(
|
||||
Cesium.Cartographic.fromDegrees(PASS.longitude, PASS.latitude),
|
||||
13,
|
||||
);
|
||||
assert.ok(tiles.every((tile) => tile.x >= here.x && tile.y <= here.y));
|
||||
// Bounded, and a tile already asked for is skipped.
|
||||
assert.equal(tilesForPoints(mercator, points, 15, { maxTiles: 3 }).length, 3);
|
||||
const asked = new Set(tiles.map((tile) => tile.key));
|
||||
assert.deepEqual(
|
||||
tilesForPoints(mercator, points, 13, { skip: (key) => asked.has(key) }),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(tilesForPoints({}, points, 13), []);
|
||||
});
|
||||
@@ -154,6 +154,12 @@ export const DATA_CREDITS = [
|
||||
'<a href="https://www.ucsusa.org/satellite_database" target="_blank" rel="noopener">UCS Satellite Database</a> ' +
|
||||
'(UCS-Satellite-Database 5-1-2023), Union of Concerned Scientists',
|
||||
},
|
||||
{
|
||||
key: 'imaging-sensors',
|
||||
html:
|
||||
'Imaging sensor swaths: operator and agency specifications, ' +
|
||||
'cited per platform',
|
||||
},
|
||||
{
|
||||
key: 'launch-library-2',
|
||||
html:
|
||||
|
||||
@@ -52,6 +52,15 @@ test('the UCS Satellite Database is acknowledged with its version and webpage',
|
||||
);
|
||||
});
|
||||
|
||||
test('the imaging sensor swaths credit operator and agency specifications', () => {
|
||||
// Each sensor-pack entry cites its operator's or agency's own pages;
|
||||
// no aggregator database is credited.
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'imaging-sensors');
|
||||
assert.ok(credit, 'the orbital zoom caps come from the sensor pack');
|
||||
assert.match(credit.html, /operator and agency specifications/);
|
||||
assert.doesNotMatch(credit.html, /OSCAR|wmo\.int/);
|
||||
});
|
||||
|
||||
test('OpenStreetMap has one generic data credit with separate tile and names distributors', () => {
|
||||
const osm = DATA_CREDITS.filter((entry) =>
|
||||
entry.html.includes('openstreetmap.org/copyright'),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @file The bundled imaging-sensor pack: swath and resolution for the main
|
||||
* Earth-observation platforms, matched to satellites by NORAD number or name.
|
||||
*
|
||||
* Data: `local_data/imaging_sensors/imaging_sensors.json`; sources, licences
|
||||
* and curation in that folder's README. Loaded once, on demand, by the
|
||||
* satellites layer; a failed load is retried after a cooldown rather than
|
||||
* remembered for the session.
|
||||
*
|
||||
* @module data/imagingSensors
|
||||
*/
|
||||
|
||||
import { indexImagingSensors } from '../layers/satellites/sensors.js';
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
const PACK_URL = new URL(
|
||||
'./local_data/imaging_sensors/imaging_sensors.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
/** The pack, loaded and indexed once. */
|
||||
export const loadImagingSensors = createRetryableLoader(async () =>
|
||||
indexImagingSensors(await loadBundledJson(PACK_URL)),
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
# Imaging sensor pack
|
||||
|
||||
Swath widths and ground resolutions for 46 Earth-observation platforms and
|
||||
constellations, matched to satellites by NORAD catalog number or by their
|
||||
CelesTrak name. The orbital view draws a satellite's sensor swath to scale and
|
||||
lets it zoom past 8x only as far as that swath (`src/layers/satellites/sensors.js`,
|
||||
`src/data/imagingSensors.js`, `orbitalMaxZoom` in `src/cockpitOrbital.js`).
|
||||
|
||||
| File | Content | Entries |
|
||||
|------|---------|---------|
|
||||
| `imaging_sensors.json` | Platform, match rule, operator, instruments, altitude, imaging modes (swath km, best ground resolution m), narrowest standard swath, sources, unverified fields | 46 |
|
||||
|
||||
**Compiled:** 2026-11-04, by hand from the pages cited per entry (`sources`).
|
||||
Each match pattern was checked against CelesTrak's active list that day.
|
||||
Altitude is the nominal value from a cited source; where none was found it is
|
||||
the median altitude of the matching satellites from CelesTrak GP data, noted in
|
||||
`unverified`. The app zooms on the live altitude, not this one.
|
||||
|
||||
**Narrowest standard swath** is the narrowest continuous standard imaging swath
|
||||
or scene width (cross-track); for a geostationary imager, the narrower side of
|
||||
its smallest standard scan area. Some listed modes are left out of it:
|
||||
|
||||
- Sampling modes: SAR wave vignettes, and Himawari's Landmark Area scans
|
||||
(1000 × 500 km), so Himawari's narrowest is the 1000 km Target Area.
|
||||
- Generations the operator has retired: PlanetScope's Dove Classic and Dove-R
|
||||
(about 25 km scenes, imagery until April 2022 per Planet) stay in `modes`,
|
||||
while its narrowest is the SuperDove's 32.5 km. The `^FLOCK ` name rule
|
||||
would also match those older Doves.
|
||||
|
||||
**Unverified values.** A value not confirmed on an opened page is named in the
|
||||
entry's `unverified` list. `swathConfirmed` is false when the narrowest swath is
|
||||
missing or unverified; such a platform keeps the base 1–8x zoom steps:
|
||||
|
||||
- Landsat 9 (swath inferred from Landsat 8), ResourceSat-2 (LISS-3 swath),
|
||||
SuperView Neo-1 (12 km from search snippets only), BlackSky and Meteosat
|
||||
MTG FCI (no swath found).
|
||||
|
||||
## Sources and licences
|
||||
|
||||
The values are individual specification facts, each cited from the
|
||||
operator's or agency's official pages listed per entry in `sources`. No text or
|
||||
images are copied. The main sources:
|
||||
|
||||
- **Operator and agency pages and spec sheets** (NASA, USGS, NOAA/NESDIS,
|
||||
ESA SentiWiki and Earth Online, EUMETSAT, JAXA, ISRO/NRSC, INPE, CSA, DLR,
|
||||
JMA, ASI, KARI, China's Ministry of Natural Resources (LASAC), Planet,
|
||||
Vantor (formerly Maxar) datasheet pages, L3Harris, China Siwei, BlackSky,
|
||||
Airbus and KOMPSAT-5 via the Disasters Charter, ICEYE, Capella, Umbra,
|
||||
Satellogic, Chang Guang).
|
||||
- **Academic papers and proceedings** (Sensors papers on Gaofen-3, KARI's
|
||||
ACRS 2012 paper on KOMPSAT-3, CAST's IAC-16 abstract on Gaofen-2).
|
||||
- **eoPortal** (ESA's mission directory, an institutional source): a swath
|
||||
read there counts as confirmed. It is cited beside the operator's and
|
||||
agency's pages, and alone for values they do not give (the wider modes of
|
||||
Gaofen-3 and COSMO-SkyMed Second Generation, and a few other entries).
|
||||
Gaofen-1 takes eoPortal's 69 km PMS and 830 km WFV swaths by maintainer
|
||||
choice; a 2019 Chinese lecture and a Sensors paper give 60 and 800 km.
|
||||
- **CEOS MIM Database** (https://database.eohandbook.com): CC BY 4.0 since
|
||||
7 September 2026, with the credit "CEOS MIM Database,
|
||||
https://database.eohandbook.com". No entry relies on it yet; credit it if
|
||||
values are taken from it later.
|
||||
|
||||
Listed in DATA_SOURCES.md and in the app's Data attribution popover.
|
||||
@@ -0,0 +1 @@
|
||||
{"version":1,"generated":"2026-11-04","entries":[{"id":"landsat-8-9","label":"Landsat 8 / Landsat 9","match":{"norad":[39084,49260],"nameRegex":"^LANDSAT [89]$"},"operator":"NASA / USGS","sensor":"OLI / OLI-2 / TIRS / TIRS-2","type":"multispectral / thermal","altitudeKm":705,"modes":[{"name":"OLI / OLI-2 · Standard scene","swathKm":185,"gsdM":15},{"name":"TIRS / TIRS-2 · Standard scene","swathKm":185,"gsdM":100}],"narrowestSwathKm":185,"swathConfirmed":false,"sources":["https://www.usgs.gov/landsat-missions/landsat-8","https://www.usgs.gov/landsat-missions/landsat-9"],"unverified":["Landsat 9 swath (inferred from Landsat 8)"]},{"id":"sentinel-2","label":"Sentinel-2A/B/C","match":{"norad":[40697,42063,60989],"nameRegex":"^SENTINEL-2[A-D]$"},"operator":"ESA / EU Copernicus","sensor":"MSI","type":"multispectral","altitudeKm":786,"modes":[{"name":"MSI · Nominal","swathKm":290,"gsdM":10}],"narrowestSwathKm":290,"swathConfirmed":true,"sources":["https://sentiwiki.copernicus.eu/web/s2-mission"],"unverified":[]},{"id":"sentinel-1","label":"Sentinel-1A/C/D","match":{"norad":[39634,62261,66315],"nameRegex":"^SENTINEL-1[A-D]$"},"operator":"ESA / EU Copernicus","sensor":"C-SAR","type":"SAR","altitudeKm":693,"modes":[{"name":"C-SAR · Stripmap (SM)","swathKm":80,"gsdM":5},{"name":"C-SAR · Interferometric Wide (IW)","swathKm":250,"gsdM":5},{"name":"C-SAR · Extra Wide (EW)","swathKm":400,"gsdM":20},{"name":"C-SAR · Wave (WV)","swathKm":null,"gsdM":5}],"narrowestSwathKm":80,"swathConfirmed":true,"sources":["https://sentiwiki.copernicus.eu/web/s1-mission"],"unverified":[]},{"id":"sentinel-3","label":"Sentinel-3A/B/C","match":{"norad":[41335,43437,100690],"nameRegex":"^SENTINEL-3[A-D]$"},"operator":"ESA / EUMETSAT (Copernicus)","sensor":"OLCI / SLSTR","type":"multispectral / thermal","altitudeKm":814.5,"modes":[{"name":"OLCI · Full resolution","swathKm":1270,"gsdM":300},{"name":"SLSTR · Nadir view","swathKm":1400,"gsdM":500},{"name":"SLSTR · Oblique view","swathKm":740,"gsdM":500}],"narrowestSwathKm":740,"swathConfirmed":true,"sources":["https://sentiwiki.copernicus.eu/web/s3-mission"],"unverified":[]},{"id":"terra-aqua-modis","label":"Terra / Aqua","match":{"norad":[25994,27424],"nameRegex":"^(TERRA|AQUA)$"},"operator":"NASA","sensor":"MODIS","type":"multispectral","altitudeKm":705,"modes":[{"name":"MODIS · Cross-track scan","swathKm":2330,"gsdM":250}],"narrowestSwathKm":2330,"swathConfirmed":true,"sources":["https://ladsweb.modaps.eosdis.nasa.gov/missions-and-measurements/modis/"],"unverified":["Aqua altitude (assumed same as Terra)"]},{"id":"viirs","label":"Suomi NPP / NOAA-20 / NOAA-21","match":{"norad":[37849,43013,54234],"nameRegex":"^(SUOMI NPP|NOAA 2[01] \\(JPSS-[12]\\))$"},"operator":"NASA / NOAA (JPSS)","sensor":"VIIRS","type":"multispectral","altitudeKm":830,"modes":[{"name":"VIIRS · Cross-track scan","swathKm":3000,"gsdM":375}],"narrowestSwathKm":3000,"swathConfirmed":true,"sources":["https://ladsweb.modaps.eosdis.nasa.gov/missions-and-measurements/viirs/","https://www.earthdata.nasa.gov/data/catalog/laads-vj102img-2.1"],"unverified":[]},{"id":"goes-abi","label":"GOES-16/17/18/19 (GOES-R series)","match":{"norad":[41866,43226,51850,60133],"nameRegex":"^GOES 1[6-9]$"},"operator":"NOAA / NASA","sensor":"ABI","type":"multispectral","altitudeKm":35786,"modes":[{"name":"ABI · Full disk","swathKm":null,"gsdM":500},{"name":"ABI · CONUS","swathKm":3000,"gsdM":500},{"name":"ABI · Mesoscale sector","swathKm":1000,"gsdM":500}],"narrowestSwathKm":1000,"swathConfirmed":true,"sources":["https://www.nesdis.noaa.gov/advanced-baseline-imager-scan-mode-information"],"unverified":["altitudeKm (standard GEO value)"]},{"id":"himawari-ahi","label":"Himawari-8 / Himawari-9","match":{"norad":[40267,41836],"nameRegex":"^HIMAWARI-[89]$"},"operator":"JMA","sensor":"AHI","type":"multispectral","altitudeKm":35786,"modes":[{"name":"AHI · Full Disk","swathKm":11000,"gsdM":500},{"name":"AHI · Japan Area","swathKm":2400,"gsdM":500},{"name":"AHI · Target Area","swathKm":1000,"gsdM":500},{"name":"AHI · Landmark Area","swathKm":500,"gsdM":500}],"narrowestSwathKm":1000,"swathConfirmed":true,"sources":["https://www.data.jma.go.jp/mscweb/en/himawari89/space_segment/spsg_ahi.html"],"unverified":["altitudeKm (standard GEO value)"]},{"id":"mtg-fci","label":"Meteosat Third Generation imager (Meteosat-12 / MTG-I2)","match":{"norad":[54743,100530],"nameRegex":"^(METEOSAT-12|MTG-I\\d)"},"operator":"EUMETSAT","sensor":"FCI","type":"multispectral","altitudeKm":35786,"modes":[{"name":"FCI · Full disc","swathKm":null,"gsdM":1000},{"name":"FCI · Rapid scan (Europe, 1/4 disc)","swathKm":null,"gsdM":500}],"narrowestSwathKm":null,"swathConfirmed":false,"sources":["https://www.esa.int/Applications/Observing_the_Earth/Meteorological_missions/Meteosat/About_Meteosat_Third_Generation","https://www-cdn.eumetsat.int/files/2020-04/pdf_mtg_goesr_ws_1.pdf"],"unverified":["narrowestSwathKm","altitudeKm (standard GEO value)"]},{"id":"gaofen-1","label":"GaoLine truncated
|
||||
@@ -3573,6 +3573,13 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "يعطي SATCAT من CelesTrak نوع كل جسم وحالته. من دون نسخة حديثة تستند الفئات إلى قاعدة بيانات UCS والأسماء ومجموعات CelesTrak، فقد يظهر قمر صناعي توقف عن العمل منذ ذلك الحين وكأنه يعمل.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "عرض نطاق المستشعر {value} كم",
|
||||
"SENSOR SWATH ≈ {value} KM": "عرض نطاق المستشعر ≈ {value} كم",
|
||||
"SENSOR": "المستشعر",
|
||||
"Zoom to the imaging sensor swath": "التكبير إلى نطاق مستشعر التصوير",
|
||||
"<1 H OLD": "قبل أقل من ساعة",
|
||||
"{value} H OLD": "قبل {value} س",
|
||||
"{value} D OLD": "قبل {value} ي",
|
||||
"FWD": "أمام",
|
||||
"AFT": "خلف",
|
||||
"RIGHT": "يمين",
|
||||
|
||||
@@ -3269,6 +3269,13 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT liefert Typ und Status jedes Objekts. Ohne aktuelle Kopie stützen sich die Klassen auf die UCS-Datenbank, Namen und CelesTrak-Gruppen, sodass ein inzwischen ausgefallener Satellit noch als aktiv erscheinen kann.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "SENSORSTREIFEN {value} KM",
|
||||
"SENSOR SWATH ≈ {value} KM": "SENSORSTREIFEN ≈ {value} KM",
|
||||
"SENSOR": "SENSOR",
|
||||
"Zoom to the imaging sensor swath": "Auf den Streifen des Bildsensors zoomen",
|
||||
"<1 H OLD": "<1 STD ALT",
|
||||
"{value} H OLD": "{value} STD ALT",
|
||||
"{value} D OLD": "{value} T ALT",
|
||||
"FWD": "VORAUS",
|
||||
"AFT": "ACHTERN",
|
||||
"RIGHT": "RECHTS",
|
||||
|
||||
@@ -3345,6 +3345,13 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak da el tipo y el estado de cada objeto. Sin una copia actual, las clases se basan en la base de datos UCS, los nombres y los grupos de CelesTrak, así que un satélite que haya dejado de funcionar puede seguir apareciendo como operativo.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "BARRIDO DEL SENSOR {value} KM",
|
||||
"SENSOR SWATH ≈ {value} KM": "BARRIDO DEL SENSOR ≈ {value} KM",
|
||||
"SENSOR": "SENSOR",
|
||||
"Zoom to the imaging sensor swath": "Ampliar al barrido del sensor de imagen",
|
||||
"<1 H OLD": "HACE <1 H",
|
||||
"{value} H OLD": "HACE {value} H",
|
||||
"{value} D OLD": "HACE {value} D",
|
||||
"FWD": "ADELANTE",
|
||||
"AFT": "ATRÁS",
|
||||
"RIGHT": "DERECHA",
|
||||
|
||||
@@ -3345,6 +3345,13 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l’état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "FAUCHÉE CAPTEUR {value} KM",
|
||||
"SENSOR SWATH ≈ {value} KM": "FAUCHÉE CAPTEUR ≈ {value} KM",
|
||||
"SENSOR": "CAPTEUR",
|
||||
"Zoom to the imaging sensor swath": "Zoomer sur la fauchée du capteur imageur",
|
||||
"<1 H OLD": "IL Y A <1 H",
|
||||
"{value} H OLD": "IL Y A {value} H",
|
||||
"{value} D OLD": "IL Y A {value} J",
|
||||
"FWD": "AVANT",
|
||||
"AFT": "ARRIÈRE",
|
||||
"RIGHT": "DROITE",
|
||||
|
||||
@@ -3345,6 +3345,13 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT di CelesTrak fornisce tipo e stato di ogni oggetto. Senza una copia aggiornata le classi si basano sul database UCS, sui nomi e sui gruppi CelesTrak, quindi un satellite che nel frattempo ha cessato di funzionare può ancora risultare operativo.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "STRISCIATA SENSORE {value} KM",
|
||||
"SENSOR SWATH ≈ {value} KM": "STRISCIATA SENSORE ≈ {value} KM",
|
||||
"SENSOR": "SENSORE",
|
||||
"Zoom to the imaging sensor swath": "Zoom sulla strisciata del sensore di ripresa",
|
||||
"<1 H OLD": "<1 H FA",
|
||||
"{value} H OLD": "{value} H FA",
|
||||
"{value} D OLD": "{value} G FA",
|
||||
"FWD": "AVANTI",
|
||||
"AFT": "INDIETRO",
|
||||
"RIGHT": "DESTRA",
|
||||
|
||||
@@ -3193,6 +3193,13 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT は各物体の種別と運用状態を示します。最新のコピーがない間、分類は UCS データベース、名称、CelesTrak のグループのみに基づくため、その後機能を失った衛星がまだ稼働中と表示されることがあります。",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "センサー観測幅 {value} km",
|
||||
"SENSOR SWATH ≈ {value} KM": "センサー観測幅 ≈ {value} km",
|
||||
"SENSOR": "センサー",
|
||||
"Zoom to the imaging sensor swath": "撮像センサーの観測幅までズーム",
|
||||
"<1 H OLD": "1時間未満前",
|
||||
"{value} H OLD": "{value}時間前",
|
||||
"{value} D OLD": "{value}日前",
|
||||
"FWD": "前方",
|
||||
"AFT": "後方",
|
||||
"RIGHT": "右",
|
||||
|
||||
@@ -3345,6 +3345,13 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "O SATCAT da CelesTrak dá o tipo e o estado de cada objeto. Sem uma cópia atual, as classes se baseiam no banco de dados UCS, nos nomes e nos grupos da CelesTrak, então um satélite que deixou de funcionar pode ainda aparecer como operacional.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "FAIXA DO SENSOR {value} KM",
|
||||
"SENSOR SWATH ≈ {value} KM": "FAIXA DO SENSOR ≈ {value} KM",
|
||||
"SENSOR": "SENSOR",
|
||||
"Zoom to the imaging sensor swath": "Aproximar até a faixa do sensor de imagem",
|
||||
"<1 H OLD": "HÁ <1 H",
|
||||
"{value} H OLD": "HÁ {value} H",
|
||||
"{value} D OLD": "HÁ {value} D",
|
||||
"FWD": "FRENTE",
|
||||
"AFT": "TRÁS",
|
||||
"RIGHT": "DIREITA",
|
||||
|
||||
@@ -3421,6 +3421,13 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT сообщает тип и состояние каждого объекта. Без актуальной копии классы опираются на базу UCS, названия и группы CelesTrak, поэтому вышедший с тех пор из строя спутник может по-прежнему показываться работающим.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "ПОЛОСА СЕНСОРА {value} КМ",
|
||||
"SENSOR SWATH ≈ {value} KM": "ПОЛОСА СЕНСОРА ≈ {value} КМ",
|
||||
"SENSOR": "СЕНСОР",
|
||||
"Zoom to the imaging sensor swath": "Приблизить до полосы съёмочного сенсора",
|
||||
"<1 H OLD": "<1 Ч НАЗАД",
|
||||
"{value} H OLD": "{value} Ч НАЗАД",
|
||||
"{value} D OLD": "{value} Д НАЗАД",
|
||||
"FWD": "ВПЕРЁД",
|
||||
"AFT": "НАЗАД",
|
||||
"RIGHT": "ВПРАВО",
|
||||
|
||||
@@ -3421,6 +3421,13 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT повідомляє тип і стан кожного об’єкта. Без актуальної копії класи спираються на базу UCS, назви та групи CelesTrak, тож супутник, що відтоді вийшов з ладу, може й далі показуватися робочим.",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "СМУГА СЕНСОРА {value} КМ",
|
||||
"SENSOR SWATH ≈ {value} KM": "СМУГА СЕНСОРА ≈ {value} КМ",
|
||||
"SENSOR": "СЕНСОР",
|
||||
"Zoom to the imaging sensor swath": "Наблизити до смуги знімального сенсора",
|
||||
"<1 H OLD": "<1 ГОД ТОМУ",
|
||||
"{value} H OLD": "{value} ГОД ТОМУ",
|
||||
"{value} D OLD": "{value} ДН ТОМУ",
|
||||
"FWD": "ВПЕРЕД",
|
||||
"AFT": "НАЗАД",
|
||||
"RIGHT": "ПРАВОРУЧ",
|
||||
|
||||
@@ -3193,6 +3193,13 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT 给出每个物体的类型和运行状态。没有最新副本时,分类仅依据 UCS 数据库、名称和 CelesTrak 分组,因此此后已失效的卫星可能仍显示为在轨工作。",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "传感器幅宽 {value} km",
|
||||
"SENSOR SWATH ≈ {value} KM": "传感器幅宽 ≈ {value} km",
|
||||
"SENSOR": "传感器",
|
||||
"Zoom to the imaging sensor swath": "缩放到成像传感器幅宽",
|
||||
"<1 H OLD": "不到1小时前",
|
||||
"{value} H OLD": "{value}小时前",
|
||||
"{value} D OLD": "{value}天前",
|
||||
"FWD": "前方",
|
||||
"AFT": "后方",
|
||||
"RIGHT": "右",
|
||||
|
||||
@@ -3193,6 +3193,13 @@
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT 提供每個物體的類型與運作狀態。沒有最新副本時,分類僅依據 UCS 資料庫、名稱與 CelesTrak 群組,因此此後已失效的衛星可能仍顯示為運作中。",
|
||||
"STARLINK ···": "STARLINK ···",
|
||||
"STARLINK ✕": "STARLINK ✕",
|
||||
"SENSOR SWATH {value} KM": "感測器幅寬 {value} km",
|
||||
"SENSOR SWATH ≈ {value} KM": "感測器幅寬 ≈ {value} km",
|
||||
"SENSOR": "感測器",
|
||||
"Zoom to the imaging sensor swath": "縮放到成像感測器幅寬",
|
||||
"<1 H OLD": "不到1小時前",
|
||||
"{value} H OLD": "{value}小時前",
|
||||
"{value} D OLD": "{value}天前",
|
||||
"FWD": "前方",
|
||||
"AFT": "後方",
|
||||
"RIGHT": "右",
|
||||
|
||||
@@ -155,6 +155,7 @@
|
||||
"Rock",
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
"SENSOR",
|
||||
"SIG",
|
||||
"SNAPSHOT",
|
||||
"SQUAWK {code} · {status}",
|
||||
@@ -317,6 +318,7 @@
|
||||
"SAR",
|
||||
"SE",
|
||||
"SECTOR {lat} {lon}",
|
||||
"SENSOR",
|
||||
"SIG",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SRC {value}",
|
||||
@@ -923,6 +925,7 @@
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
"SE",
|
||||
"SENSOR",
|
||||
"SIG",
|
||||
"SNAPSHOT",
|
||||
"SQUAWK {code} · {status}",
|
||||
|
||||
@@ -130,6 +130,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
satelliteClass: 'starlink',
|
||||
ucs: null,
|
||||
satcat: null,
|
||||
sensor: null,
|
||||
});
|
||||
const point = layerState._pointCollection.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
|
||||
@@ -75,6 +75,8 @@ function satelliteIdentity(noradId, sat) {
|
||||
satelliteOwner({ ucs: sat.ucs, satcat: sat.satcat, satelliteClass }),
|
||||
),
|
||||
operator: satelliteOperator({ ucs: sat.ucs, satelliteClass }),
|
||||
// src/layers/satellites/sensors.js entry, or null: the orbital view's sensor swath.
|
||||
sensor: sat.sensor ?? null,
|
||||
});
|
||||
sat.cockpitIdentity = identity;
|
||||
return identity;
|
||||
@@ -138,6 +140,7 @@ export function satelliteCockpitInfo(noradId, sat, fix) {
|
||||
// owners), label: English name } or null.
|
||||
owner: identity.owner,
|
||||
operator: identity.operator,
|
||||
sensor: identity.sensor,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
satelliteCockpitInfo,
|
||||
} from './cockpitSubject.js';
|
||||
import { groundTrackBearingDeg } from './orbits.js';
|
||||
import { indexImagingSensors } from './sensors.js';
|
||||
import { ISS_NORAD } from './policy.js';
|
||||
|
||||
const COMPANION = 25545;
|
||||
@@ -66,6 +67,20 @@ const UCS = new Map([
|
||||
],
|
||||
]);
|
||||
|
||||
// One sensor entry, matched by NORAD number, for the orbital zoom cap.
|
||||
const SENSORS = indexImagingSensors({
|
||||
entries: [
|
||||
{
|
||||
id: 'fixture-imager',
|
||||
label: 'Fixture imager',
|
||||
match: { norad: [GPS] },
|
||||
narrowestSwathKm: 50,
|
||||
swathConfirmed: true,
|
||||
sources: ['https://example.test/spec'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
async function fixture(t) {
|
||||
const source = {
|
||||
async readGroup(group) {
|
||||
@@ -76,6 +91,7 @@ async function fixture(t) {
|
||||
return { ok: true, status: 200, text: SATCAT_TEXT[group] || '' };
|
||||
},
|
||||
readUcs: async () => UCS,
|
||||
readSensors: async () => SENSORS,
|
||||
};
|
||||
const noop = () => {};
|
||||
const services = {
|
||||
@@ -259,6 +275,10 @@ test('the subject says what it is, which TLE it flies, and who owns and runs it'
|
||||
);
|
||||
assert.deepEqual(gps.owner, { countryCode: 'US', label: 'United States' });
|
||||
assert.equal(gps.operator, null);
|
||||
// The sensor pack is matched once, at ingestion.
|
||||
assert.equal(gps.sensor?.id, 'fixture-imager');
|
||||
assert.equal(gps.sensor.narrowestSwathKm, 50);
|
||||
assert.equal(iss.sensor, null, 'no sensor entry, no extra zoom');
|
||||
|
||||
// A bare catalog entry still answers every field.
|
||||
const bare = satelliteCockpitInfo(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { msg } from '../../i18n/index.js';
|
||||
import { noradIdFromSatnum, tleLineLaunchYear } from '../../sources/tle.js';
|
||||
import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js';
|
||||
import { classifySatellite, parseSatcatCsv } from './classes.js';
|
||||
import { matchImagingSensor } from './sensors.js';
|
||||
|
||||
/**
|
||||
* How long a catalog rebuild waits for SATCAT before it classifies without
|
||||
@@ -26,20 +27,38 @@ export function createIngestion({
|
||||
source,
|
||||
}) {
|
||||
/**
|
||||
* The local pack the classes are read from: the UCS pack. It is not needed
|
||||
* to render: a failed read leaves the last pack (or none) and
|
||||
* classification falls back to names and groups.
|
||||
* The local packs the classes and zoom steps are read from: the UCS pack
|
||||
* and the imaging-sensor pack. Neither is needed to render: a failed read
|
||||
* leaves the last pack (or none) and classification falls back to names
|
||||
* and groups.
|
||||
*/
|
||||
async function _readLocalEvidence() {
|
||||
if (layerState._ucs) return;
|
||||
const pack = await Promise.resolve()
|
||||
.then(() => source.readUcs?.())
|
||||
.then((loaded) => (loaded instanceof Map ? loaded : null))
|
||||
.catch((error) => {
|
||||
console.warn('[Data:Satellites] UCS pack unavailable:', error);
|
||||
return null;
|
||||
});
|
||||
const ucs = layerState._ucs
|
||||
? Promise.resolve(layerState._ucs)
|
||||
: Promise.resolve()
|
||||
.then(() => source.readUcs?.())
|
||||
.then((pack) => (pack instanceof Map ? pack : null))
|
||||
.catch((error) => {
|
||||
console.warn('[Data:Satellites] UCS pack unavailable:', error);
|
||||
return null;
|
||||
});
|
||||
// The sensor pack only adds zoom steps in the orbital view; without it
|
||||
// every satellite keeps the base steps.
|
||||
const sensors = layerState._sensors
|
||||
? Promise.resolve(layerState._sensors)
|
||||
: Promise.resolve()
|
||||
.then(() => source.readSensors?.())
|
||||
.then((index) => (index?.byNorad instanceof Map ? index : null))
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[Data:Satellites] Imaging sensor pack unavailable:',
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
const [pack, sensorIndex] = await Promise.all([ucs, sensors]);
|
||||
if (pack) layerState._ucs = pack;
|
||||
if (sensorIndex) layerState._sensors = sensorIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,6 +356,10 @@ export function createIngestion({
|
||||
}),
|
||||
ucs,
|
||||
satcat,
|
||||
sensor: matchImagingSensor(layerState._sensors, {
|
||||
noradId,
|
||||
name: entry.name,
|
||||
}),
|
||||
});
|
||||
|
||||
coreStale ||= entry.stale;
|
||||
|
||||
@@ -291,3 +291,23 @@ test('a current SATCAT read is kept between updates; a failed one is asked again
|
||||
await update();
|
||||
assert.equal(reads, 3 * perUpdate, 'a failed read is asked again');
|
||||
});
|
||||
|
||||
test('a failed imaging-sensor pack is warned about, as the UCS pack is', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const warn = t.mock.method(console, 'warn', () => {});
|
||||
const { state, update } = ingestionFor(tleSet('OLD STAGE', '40115'), {
|
||||
source: {
|
||||
readSensors: async () => {
|
||||
throw new Error('chunk failed');
|
||||
},
|
||||
},
|
||||
});
|
||||
await update();
|
||||
assert.ok(
|
||||
warn.mock.calls.some((call) =>
|
||||
/sensor pack unavailable/i.test(call.arguments[0]),
|
||||
),
|
||||
'the failure is not swallowed',
|
||||
);
|
||||
assert.equal(state._catalog.get(40115).sensor, null, 'base zoom steps');
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file Imaging sensors: which Earth-observation platform a satellite is, and
|
||||
* the swath its sensor images. The orbital view draws that swath to scale and
|
||||
* lets a satellite zoom past 8x only as far as its sensor's narrowest
|
||||
* standard swath (src/cockpitOrbital.js `orbitalMaxZoom`).
|
||||
*
|
||||
* Pure: no Cesium, no fetches; the bundled pack is loaded by
|
||||
* src/data/imagingSensors.js.
|
||||
*
|
||||
* @module imagingSensors
|
||||
*/
|
||||
|
||||
/**
|
||||
* One sensor-pack entry as the app uses it.
|
||||
* @typedef {{id: string, label: string, operator: string|null,
|
||||
* sensor: string, type: string, altitudeKm: number|null,
|
||||
* modes: Array<{name: string, swathKm: number|null, gsdM: number|null}>,
|
||||
* narrowestSwathKm: number|null, swathConfirmed: boolean,
|
||||
* sources: string[], unverified: string[]}} ImagingSensor
|
||||
*/
|
||||
|
||||
/**
|
||||
* Index the pack for matching: exact NORAD numbers first, then name patterns
|
||||
* in pack order. A malformed pattern is skipped, not fatal.
|
||||
* @param {{entries?: Array<object>}} pack Parsed `imaging_sensors.json`.
|
||||
* @returns {{byNorad: Map<number, ImagingSensor>,
|
||||
* patterns: Array<{pattern: RegExp, sensor: ImagingSensor}>}}
|
||||
*/
|
||||
export function indexImagingSensors(pack) {
|
||||
const byNorad = new Map();
|
||||
const patterns = [];
|
||||
for (const entry of pack?.entries || []) {
|
||||
if (!entry?.id) continue;
|
||||
const sensor = Object.freeze({
|
||||
id: entry.id,
|
||||
label: entry.label || entry.id,
|
||||
operator: entry.operator ?? null,
|
||||
sensor: entry.sensor || '',
|
||||
type: entry.type || '',
|
||||
altitudeKm: Number.isFinite(entry.altitudeKm) ? entry.altitudeKm : null,
|
||||
modes: Object.freeze(
|
||||
(entry.modes || []).map((mode) => Object.freeze({ ...mode })),
|
||||
),
|
||||
narrowestSwathKm: Number.isFinite(entry.narrowestSwathKm)
|
||||
? entry.narrowestSwathKm
|
||||
: null,
|
||||
swathConfirmed: entry.swathConfirmed === true,
|
||||
sources: Object.freeze([...(entry.sources || [])]),
|
||||
unverified: Object.freeze([...(entry.unverified || [])]),
|
||||
});
|
||||
for (const noradId of entry.match?.norad || []) {
|
||||
if (Number.isInteger(noradId) && !byNorad.has(noradId))
|
||||
byNorad.set(noradId, sensor);
|
||||
}
|
||||
if (entry.match?.nameRegex) {
|
||||
try {
|
||||
patterns.push({ pattern: new RegExp(entry.match.nameRegex), sensor });
|
||||
} catch {
|
||||
// A bad pattern only loses its name match; NORAD ids still work.
|
||||
}
|
||||
}
|
||||
}
|
||||
return { byNorad, patterns };
|
||||
}
|
||||
|
||||
/**
|
||||
* The sensor entry for a satellite: by NORAD number, else by its CelesTrak
|
||||
* name (upper-cased, trimmed).
|
||||
* @param {ReturnType<typeof indexImagingSensors>|null|undefined} index
|
||||
* @param {{noradId?: number, name?: string}} satellite
|
||||
* @returns {ImagingSensor|null}
|
||||
*/
|
||||
export function matchImagingSensor(index, { noradId, name } = {}) {
|
||||
if (!index) return null;
|
||||
const byId = index.byNorad?.get(noradId);
|
||||
if (byId) return byId;
|
||||
const text = String(name || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!text) return null;
|
||||
for (const { pattern, sensor } of index.patterns || [])
|
||||
if (pattern.test(text)) return sensor;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The swath the orbital view may zoom to: the narrowest standard swath, only
|
||||
* when it was confirmed on a cited page. An unconfirmed swath keeps the
|
||||
* satellite at the base zoom steps.
|
||||
* @param {ImagingSensor|null|undefined} sensor
|
||||
* @returns {number|null} Kilometres.
|
||||
*/
|
||||
export function zoomableSwathKm(sensor) {
|
||||
const swathKm = sensor?.narrowestSwathKm;
|
||||
return sensor?.swathConfirmed === true &&
|
||||
Number.isFinite(swathKm) &&
|
||||
swathKm > 0
|
||||
? swathKm
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
indexImagingSensors,
|
||||
matchImagingSensor,
|
||||
zoomableSwathKm,
|
||||
} from './sensors.js';
|
||||
import { loadImagingSensors } from '../../data/imagingSensors.js';
|
||||
|
||||
const PACK_URL = new URL(
|
||||
'../../data/local_data/imaging_sensors/imaging_sensors.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
const PACK = {
|
||||
entries: [
|
||||
{
|
||||
id: 'worldview-3',
|
||||
label: 'WorldView-3',
|
||||
match: { norad: [40115], nameRegex: '^WORLDVIEW-3' },
|
||||
narrowestSwathKm: 13.1,
|
||||
swathConfirmed: true,
|
||||
},
|
||||
{
|
||||
id: 'iceye',
|
||||
label: 'ICEYE',
|
||||
match: { nameRegex: '^ICEYE-X\\d+' },
|
||||
narrowestSwathKm: 5,
|
||||
swathConfirmed: true,
|
||||
},
|
||||
{
|
||||
id: 'broken',
|
||||
label: 'Broken pattern',
|
||||
match: { norad: [1], nameRegex: '([' },
|
||||
narrowestSwathKm: 9,
|
||||
swathConfirmed: true,
|
||||
},
|
||||
{
|
||||
id: 'blacksky',
|
||||
label: 'BlackSky',
|
||||
match: { nameRegex: '^GLOBAL-\\d+$' },
|
||||
narrowestSwathKm: null,
|
||||
swathConfirmed: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('a satellite matches its sensor by NORAD number, then by name', () => {
|
||||
const index = indexImagingSensors(PACK);
|
||||
assert.equal(
|
||||
matchImagingSensor(index, { noradId: 40115 })?.id,
|
||||
'worldview-3',
|
||||
);
|
||||
// An id match wins over a name that says otherwise.
|
||||
assert.equal(
|
||||
matchImagingSensor(index, { noradId: 40115, name: 'ICEYE-X9' })?.id,
|
||||
'worldview-3',
|
||||
);
|
||||
assert.equal(
|
||||
matchImagingSensor(index, { noradId: 99999, name: ' iceye-x14 ' })?.id,
|
||||
'iceye',
|
||||
);
|
||||
assert.equal(matchImagingSensor(index, { noradId: 1 })?.id, 'broken');
|
||||
assert.equal(matchImagingSensor(index, { name: 'STARLINK-1007' }), null);
|
||||
assert.equal(matchImagingSensor(null, { noradId: 40115 }), null);
|
||||
});
|
||||
|
||||
test('only a confirmed swath opens zoom steps past 8x', () => {
|
||||
const index = indexImagingSensors(PACK);
|
||||
assert.equal(
|
||||
zoomableSwathKm(matchImagingSensor(index, { noradId: 40115 })),
|
||||
13.1,
|
||||
);
|
||||
assert.equal(
|
||||
zoomableSwathKm(matchImagingSensor(index, { name: 'GLOBAL-21' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(zoomableSwathKm(null), null);
|
||||
assert.equal(
|
||||
zoomableSwathKm({ swathConfirmed: true, narrowestSwathKm: 0 }),
|
||||
null,
|
||||
'no swath to zoom to',
|
||||
);
|
||||
});
|
||||
|
||||
test('the bundled pack is well formed and cites its sources', () => {
|
||||
const pack = JSON.parse(readFileSync(PACK_URL, 'utf8'));
|
||||
assert.ok(pack.entries.length >= 40);
|
||||
assert.ok(
|
||||
readFileSync(PACK_URL).length < 60_000,
|
||||
'small enough for the layer',
|
||||
);
|
||||
const ids = new Set();
|
||||
for (const entry of pack.entries) {
|
||||
assert.ok(entry.id && !ids.has(entry.id), `unique id ${entry.id}`);
|
||||
ids.add(entry.id);
|
||||
assert.ok(entry.match.norad || entry.match.nameRegex, entry.id);
|
||||
if (entry.match.nameRegex) new RegExp(entry.match.nameRegex);
|
||||
assert.ok(entry.sources.length > 0, `${entry.id} cites a source`);
|
||||
if (entry.swathConfirmed) {
|
||||
assert.ok(entry.narrowestSwathKm > 0, entry.id);
|
||||
// The narrowest *standard* swath: sampling modes (Himawari's landmark
|
||||
// area) and older generations (Dove Classic) may be narrower.
|
||||
const swaths = entry.modes
|
||||
.map((mode) => mode.swathKm)
|
||||
.filter(Number.isFinite);
|
||||
assert.ok(
|
||||
swaths.includes(entry.narrowestSwathKm),
|
||||
`${entry.id}: the narrowest swath is one of its modes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the pack cites no reseller, links its datasheets and names its own fields', () => {
|
||||
const pack = JSON.parse(readFileSync(PACK_URL, 'utf8'));
|
||||
// Resellers, Gunter's Space Page, and the Copernicus portal whose terms
|
||||
// forbid reuse.
|
||||
const excluded =
|
||||
/euspaceimaging\.com|apollomapping\.com|landinfo\.com|satimagingcorp\.com|space\.skyrocket\.de|dataspace\.copernicus\.eu/;
|
||||
const fields = new Set(['altitudeKm', 'narrowestSwathKm', 'swathKm', 'gsdM']);
|
||||
for (const entry of pack.entries) {
|
||||
for (const source of entry.sources)
|
||||
assert.doesNotMatch(source, excluded, `${entry.id}: ${source}`);
|
||||
// A bare file-host datasheet is cited beside the operator page that
|
||||
// links it.
|
||||
if (entry.sources.some((source) => /cdn\.sanity\.io/.test(source)))
|
||||
assert.ok(
|
||||
entry.sources.some((source) => /^https:\/\/vantor\.com\//.test(source)),
|
||||
`${entry.id}: the Vantor page for its datasheet`,
|
||||
);
|
||||
// An unverified value is named by the pack's own field.
|
||||
for (const note of entry.unverified) {
|
||||
const [field] = /^[A-Za-z_]+/.exec(note) ?? [''];
|
||||
if (/^[a-z]+(_[a-z]+)+$|^[a-z]+[A-Z]\w*$/.test(field))
|
||||
assert.ok(fields.has(field), `${entry.id}: unknown field ${field}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the pack loads once and matches real catalog names', async () => {
|
||||
const index = await loadImagingSensors();
|
||||
assert.equal(index, await loadImagingSensors(), 'loaded once');
|
||||
const cases = [
|
||||
[{ noradId: 49260, name: 'LANDSAT 9' }, 'landsat-8-9'],
|
||||
[{ noradId: 40115, name: 'WORLDVIEW-3 (WV-3)' }, 'worldview-3'],
|
||||
[{ noradId: 1, name: 'FLOCK 4Q-12' }, 'planetscope'],
|
||||
[{ noradId: 1, name: 'ICEYE-X40' }, 'iceye'],
|
||||
[{ noradId: 1, name: 'SKYSAT-C19' }, 'skysat'],
|
||||
];
|
||||
for (const [satellite, id] of cases)
|
||||
assert.equal(matchImagingSensor(index, satellite)?.id, id, satellite.name);
|
||||
});
|
||||
@@ -20,10 +20,17 @@ async function loadBundledUcs() {
|
||||
return loadUcsSatellites();
|
||||
}
|
||||
|
||||
/** The imaging-sensor pack, loaded on first use (see src/data/imagingSensors.js). */
|
||||
async function loadBundledSensors() {
|
||||
const { loadImagingSensors } = await import('../../data/imagingSensors.js');
|
||||
return loadImagingSensors();
|
||||
}
|
||||
|
||||
/** Read catalog text from the existing group endpoint using a supplied transport. */
|
||||
export function createSatelliteSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
loadUcs = loadBundledUcs,
|
||||
loadSensors = loadBundledSensors,
|
||||
} = {}) {
|
||||
return {
|
||||
async readGroup(group, { signal } = {}) {
|
||||
@@ -60,5 +67,10 @@ export function createSatelliteSource({
|
||||
readUcs() {
|
||||
return loadUcs();
|
||||
},
|
||||
|
||||
/** The imaging-sensor pack, indexed by NORAD number and name pattern. */
|
||||
readSensors() {
|
||||
return loadSensors();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -117,6 +117,9 @@ export function createState({ services }) {
|
||||
/** The UCS pack by NORAD id, once loaded; null until then or if it failed. */
|
||||
state._ucs = null;
|
||||
|
||||
/** The imaging-sensor index, once loaded; null until then or if it failed. */
|
||||
state._sensors = null;
|
||||
|
||||
state._denseIds = [];
|
||||
// norad ids of dense extras, round-robin order
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
beginOrbital,
|
||||
endOrbital,
|
||||
resetOrbitalLook,
|
||||
jumpToSensorSwath,
|
||||
onOrbitalWheel,
|
||||
stepOrbitalZoom,
|
||||
turnOrbitalLook,
|
||||
updateOrbital,
|
||||
@@ -290,6 +292,14 @@ export class CockpitViewController {
|
||||
true,
|
||||
);
|
||||
this._listenerRemovers.push(routeSceneDoubleClick.call(this));
|
||||
// In orbit the wheel steps the optical zoom (Cesium's own wheel zoom is
|
||||
// off while the Cockpit holds the camera).
|
||||
this._listen(
|
||||
viewer.scene?.canvas,
|
||||
'wheel',
|
||||
(event) => this.onOrbitalWheel(event),
|
||||
{ passive: false },
|
||||
);
|
||||
// The readout's entry renders inside the Intel HUD, which may mount after
|
||||
// this controller; delegated like the radar's range button.
|
||||
this._listen(document, 'click', (event) => {
|
||||
@@ -338,6 +348,8 @@ export class CockpitViewController {
|
||||
else if (target?.closest?.('#cockpit-orbital-zoom-out'))
|
||||
this.stepOrbitalZoom(-1);
|
||||
else if (target?.closest?.('#cockpit-orbital-nadir')) this.orbitalNadir();
|
||||
else if (target?.closest?.('#cockpit-orbital-sensor'))
|
||||
this.jumpToSensorSwath();
|
||||
});
|
||||
this._listen(this.signalToggle, 'click', () =>
|
||||
this.setSignalCollapsed(!this.signalCollapsed, { user: true }),
|
||||
@@ -406,6 +418,12 @@ export class CockpitViewController {
|
||||
stepOrbitalZoom(direction) {
|
||||
return stepOrbitalZoom.call(this, direction);
|
||||
}
|
||||
jumpToSensorSwath() {
|
||||
return jumpToSensorSwath.call(this);
|
||||
}
|
||||
onOrbitalWheel(event) {
|
||||
return onOrbitalWheel.call(this, event);
|
||||
}
|
||||
/**
|
||||
* Tilt the orbital view one arrow step off nadir.
|
||||
* @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step
|
||||
|
||||
@@ -214,6 +214,24 @@ test('the readout names the owner, operator and element set', async (t) => {
|
||||
f.text('.cockpit-orbital-ident-text'),
|
||||
'NORAD 25544 · 1998-067A · EPOCH 2026-11-04 08:12Z',
|
||||
);
|
||||
// The element set's age, as the controller reads it on each publish.
|
||||
f.store.publishOrbital({
|
||||
...f.store.orbital,
|
||||
tleAgeMs: 13.5 * 3_600_000,
|
||||
});
|
||||
await nextTick();
|
||||
assert.equal(
|
||||
f.text('.cockpit-orbital-ident-text'),
|
||||
'NORAD 25544 · 1998-067A · EPOCH 2026-11-04 08:12Z · 13 H OLD',
|
||||
);
|
||||
f.store.publishOrbital({ ...f.store.orbital, tleAgeMs: 50 * 3_600_000 });
|
||||
await nextTick();
|
||||
assert.match(f.text('.cockpit-orbital-ident-text'), / · 2 D OLD$/);
|
||||
f.store.publishOrbital({ ...f.store.orbital, tleAgeMs: 10 * 60_000 });
|
||||
await nextTick();
|
||||
assert.match(f.text('.cockpit-orbital-ident-text'), / · <1 H OLD$/);
|
||||
f.store.publishOrbital({ ...f.store.orbital, tleAgeMs: null });
|
||||
await nextTick();
|
||||
const toggle = document.getElementById('cockpit-orbital-tle-toggle');
|
||||
const tle = document.getElementById('cockpit-orbital-tle');
|
||||
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
/** The orbital (satellite) view of the Cockpit controller: camera, zoom, look and HUD. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { formatNumber, msg, t } from '../i18n/index.js';
|
||||
import {
|
||||
ORBITAL_BASE_MAX_ZOOM,
|
||||
ORBITAL_DEFAULT_ZOOM,
|
||||
ORBITAL_PREFETCH_INTERVAL_MS,
|
||||
ORBITAL_PREFETCH_MAX_TILES,
|
||||
ORBITAL_PREFETCH_MIN_ZOOM,
|
||||
ORBITAL_SETTLE_BLUR_PX,
|
||||
ORBITAL_ZOOM_LADDER,
|
||||
ORBITAL_ZOOM_TRANSITION_MS,
|
||||
cesiumFrustumFovRad,
|
||||
footprintWidthKm,
|
||||
fovForZoom,
|
||||
groundTrackAhead,
|
||||
imageryLevelForView,
|
||||
interpolateZoomFov,
|
||||
nadirOrientation,
|
||||
nextOrbitalZoom,
|
||||
@@ -16,7 +21,12 @@ import {
|
||||
offNadirOrientation,
|
||||
offNadirSwathScale,
|
||||
orbitalBaseFovRad,
|
||||
orbitalMaxZoom,
|
||||
previousOrbitalZoom,
|
||||
sensorStripFraction,
|
||||
sensorSwathZoom,
|
||||
tilesForPoints,
|
||||
wheelZoomStep,
|
||||
zoomSettleHolding,
|
||||
zoomTransitionState,
|
||||
} from '../cockpitOrbital.js';
|
||||
@@ -28,6 +38,8 @@ import {
|
||||
} from '../cockpitLook.js';
|
||||
import { cockpitUiUpdateDue } from '../cockpitMath.js';
|
||||
import { createPlaceTracker, nearPlaceRadiusKm } from '../data/placeBelow.js';
|
||||
import { isPointerFree } from '../data/inputOwnership.js';
|
||||
import { zoomableSwathKm } from '../layers/satellites/sensors.js';
|
||||
import {
|
||||
COCKPIT_CONTEXT_UPDATE_MS,
|
||||
COCKPIT_HUD_UPDATE_MS,
|
||||
@@ -66,6 +78,12 @@ export function beginOrbital() {
|
||||
const state = {
|
||||
imagery: acquireOrbitImagery(this.services),
|
||||
zoom: ORBITAL_DEFAULT_ZOOM,
|
||||
// Wheel scrolling summed toward the next zoom step (wheelZoomStep).
|
||||
wheel: null,
|
||||
// Imagery pre-loaded ahead of the ground track from 16x: when it last
|
||||
// looked ahead, and the tiles already asked for (oldest first).
|
||||
prefetchMs: -Infinity,
|
||||
prefetched: new Set(),
|
||||
// A zoom step in flight: the magnification it started from and when.
|
||||
transition: null,
|
||||
previousUp: null,
|
||||
@@ -283,6 +301,14 @@ export function updateOrbital(nowMs) {
|
||||
if (!nadir) return;
|
||||
state.previousUp = nadir.north;
|
||||
state.altitudeM = info.altitudeM;
|
||||
// The top step follows live altitude and the window's shape: when it
|
||||
// falls below the zoom shown, step back to it rather than show finer
|
||||
// ground than the sensor images.
|
||||
const maxZoom = orbitalMaxZoomFor.call(this, info);
|
||||
if (state.zoom > maxZoom) {
|
||||
this.lastSubjectInfo = info;
|
||||
goToZoom.call(this, maxZoom);
|
||||
}
|
||||
state.lookView = easeLook(
|
||||
state.lookView,
|
||||
state.lookTarget,
|
||||
@@ -322,6 +348,136 @@ export function updateOrbital(nowMs) {
|
||||
this.lastContextUpdateMs = nowMs;
|
||||
updateOrbitalContext.call(this, info, nowMs);
|
||||
}
|
||||
prefetchImageryAhead.call(this, info, nowMs);
|
||||
}
|
||||
|
||||
/** Tiles remembered as asked for in one orbital session. */
|
||||
const PREFETCH_MEMORY = 512;
|
||||
|
||||
/**
|
||||
* From 16x the view is 10 to 40 km wide while the ground track moves about
|
||||
* 7 km/s, so the imagery under it arrives late. Every
|
||||
* ORBITAL_PREFETCH_INTERVAL_MS ask the globe's base imagery for the tiles
|
||||
* the view will need 5 to 20 s ahead (`groundTrackAhead`), at the level
|
||||
* Cesium will draw there (`imageryLevelForView`), so the browser has them
|
||||
* cached when the view gets there. The requests are throttled at the
|
||||
* lowest priority: Cesium's request scheduler declines one while the
|
||||
* imagery server's slots are taken by the tiles on screen or the scheduler
|
||||
* is full, and a declined tile is asked again on a later look-ahead. At most
|
||||
* ORBITAL_PREFETCH_MAX_TILES a time; the last PREFETCH_MEMORY tiles asked
|
||||
* for are not asked again. Nothing below 16x, while the view is tilted off
|
||||
* nadir (it no longer looks at the track), after the exit, or with the globe
|
||||
* hidden (Google 3D tiles drawn instead).
|
||||
* @returns {number} The tiles asked for.
|
||||
*/
|
||||
export function prefetchImageryAhead(info, nowMs) {
|
||||
const state = this.orbital;
|
||||
if (!state || !(state.zoom >= ORBITAL_PREFETCH_MIN_ZOOM)) return 0;
|
||||
// Tilted, the view looks off the track the pre-load follows.
|
||||
if (lookIsOffset(state.lookTarget, 0) || lookIsOffset(state.lookView, 0))
|
||||
return 0;
|
||||
if (nowMs - state.prefetchMs < ORBITAL_PREFETCH_INTERVAL_MS) return 0;
|
||||
state.prefetchMs = nowMs;
|
||||
try {
|
||||
const globe = this.viewer.scene?.globe;
|
||||
if (!globe || globe.show === false) return 0;
|
||||
const provider = baseImageryProvider(this.viewer);
|
||||
if (!provider) return 0;
|
||||
const fov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom);
|
||||
const viewKm = footprintWidthKm(
|
||||
info.altitudeM,
|
||||
fov,
|
||||
viewAspect(this.viewer),
|
||||
);
|
||||
// The ground one CSS pixel covers at the nadir point, as Cesium's
|
||||
// screen-space error measures it: the vertical field of view over the
|
||||
// canvas height, at the satellite's altitude.
|
||||
const canvas = this.viewer.scene?.canvas;
|
||||
const heightPx = canvas?.clientHeight || canvas?.height;
|
||||
const terrain =
|
||||
this.viewer.terrainProvider ?? this.viewer.scene?.globe?.terrainProvider;
|
||||
const level = imageryLevelForView(provider.tilingScheme, {
|
||||
metresPerPixel: (info.altitudeM * 2 * Math.tan(fov / 2)) / heightPx,
|
||||
latitudeDeg: info.latitude,
|
||||
geographic:
|
||||
provider.tilingScheme.projection instanceof Cesium.GeographicProjection,
|
||||
tileWidthPx: provider.tileWidth,
|
||||
minimumLevel: provider.minimumLevel,
|
||||
maximumLevel: provider.maximumLevel,
|
||||
maximumScreenSpaceError: globe.maximumScreenSpaceError,
|
||||
terrainLevelErrorM:
|
||||
typeof terrain?.getLevelMaximumGeometricError === 'function'
|
||||
? (terrainLevel) =>
|
||||
terrain.getLevelMaximumGeometricError(terrainLevel)
|
||||
: undefined,
|
||||
});
|
||||
if (level === null) return 0;
|
||||
const memory = state.prefetched;
|
||||
const tiles = tilesForPoints(
|
||||
provider.tilingScheme,
|
||||
groundTrackAhead(info, viewKm),
|
||||
level,
|
||||
{
|
||||
maxTiles: ORBITAL_PREFETCH_MAX_TILES,
|
||||
skip: (key) => memory.has(key),
|
||||
},
|
||||
);
|
||||
let asked = 0;
|
||||
for (const tile of tiles) {
|
||||
let pending;
|
||||
try {
|
||||
// The tiles on screen keep their slots: Cesium's scheduler declines
|
||||
// this request while the imagery server's slots are taken (shared
|
||||
// with the globe's own tile requests, throttleByServer) or the
|
||||
// scheduler is full, and at the lowest priority it is the first
|
||||
// dropped from the priority heap, never one the screen asked for.
|
||||
pending = provider.requestImage(
|
||||
tile.x,
|
||||
tile.y,
|
||||
tile.level,
|
||||
new Cesium.Request({
|
||||
throttle: true,
|
||||
throttleByServer: true,
|
||||
priority: Number.MAX_VALUE,
|
||||
type: Cesium.RequestType.IMAGERY,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
// A provider that declines now (throttled) is asked again later.
|
||||
if (!pending) continue;
|
||||
Promise.resolve(pending).catch(() => {});
|
||||
memory.add(tile.key);
|
||||
if (memory.size > PREFETCH_MEMORY)
|
||||
memory.delete(memory.values().next().value);
|
||||
asked += 1;
|
||||
}
|
||||
return asked;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider of the lowest shown imagery layer, the base map the globe
|
||||
* drapes, when it can be asked for a tile; else null.
|
||||
*/
|
||||
function baseImageryProvider(viewer) {
|
||||
const layers = viewer.imageryLayers ?? viewer.scene?.imageryLayers;
|
||||
const count = Number.isInteger(layers?.length) ? layers.length : 0;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const layer = layers.get(i);
|
||||
if (!layer || layer.show === false || layer.alpha === 0) continue;
|
||||
const provider = layer.imageryProvider;
|
||||
if (
|
||||
typeof provider?.requestImage === 'function' &&
|
||||
typeof provider.tilingScheme?.positionToTileXY === 'function'
|
||||
)
|
||||
return provider;
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -409,6 +565,8 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) {
|
||||
: canvasAspect(this.viewer);
|
||||
const settledFov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom);
|
||||
const footprintKm = footprintWidthKm(info.altitudeM, settledFov, aspect);
|
||||
const maxZoom = orbitalMaxZoomFor.call(this, info);
|
||||
const swathKm = sensorSwathKm(info);
|
||||
const trackUp = Number.isFinite(info.groundTrackDeg);
|
||||
const swathScale = offset
|
||||
? offNadirSwathScale(lookTilt(orbitalLookReadoutParts(state.lookTarget)))
|
||||
@@ -421,9 +579,22 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) {
|
||||
latitude: info.latitude,
|
||||
longitude: info.longitude,
|
||||
zoom: state.zoom,
|
||||
zoomSteps: [...ORBITAL_ZOOM_LADDER],
|
||||
maxZoom: ORBITAL_BASE_MAX_ZOOM,
|
||||
zoomSteps: ORBITAL_ZOOM_LADDER.filter((step) => step <= maxZoom),
|
||||
maxZoom,
|
||||
footprintKm: offset ? null : footprintKm,
|
||||
// The imaging sensor's swath, drawn to scale; null without one. The
|
||||
// SENSOR step's zoom is null when the swath lies outside 1x..maxZoom.
|
||||
sensor:
|
||||
swathKm === null
|
||||
? null
|
||||
: {
|
||||
label: info.sensor?.label ?? null,
|
||||
swathKm,
|
||||
// Off nadir the view and the swath widen alike: the strip
|
||||
// keeps its share of the frame.
|
||||
stripFraction: sensorStripFraction(swathKm, footprintKm),
|
||||
zoom: sensorSwathZoom(info.altitudeM, swathKm, aspect, maxZoom),
|
||||
},
|
||||
transition: { ...transition },
|
||||
look: {
|
||||
offset,
|
||||
@@ -444,6 +615,10 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) {
|
||||
noradId: info.noradId ?? null,
|
||||
cosparId: info.cosparId ?? null,
|
||||
tle: info.tle ?? null,
|
||||
// Read on the HUD's cadence, so the age stays current without a timer.
|
||||
tleAgeMs: Number.isFinite(info.tle?.epochMs)
|
||||
? Date.now() - info.tle.epochMs
|
||||
: null,
|
||||
owner: info.owner ?? null,
|
||||
operator: info.operator ?? null,
|
||||
});
|
||||
@@ -490,11 +665,82 @@ export function stepOrbitalZoom(direction) {
|
||||
const state = this.orbital;
|
||||
const next =
|
||||
direction > 0
|
||||
? nextOrbitalZoom(state.zoom)
|
||||
? nextOrbitalZoom(
|
||||
state.zoom,
|
||||
orbitalMaxZoomFor.call(this, this.lastSubjectInfo),
|
||||
)
|
||||
: previousOrbitalZoom(state.zoom);
|
||||
return goToZoom.call(this, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Jump to the zoom at which the view is exactly as wide as the imaging
|
||||
* sensor's swath (the SENSOR step), when that zoom is in range.
|
||||
* @returns {boolean} True when the zoom changed.
|
||||
*/
|
||||
export function jumpToSensorSwath() {
|
||||
if (!this.active || this.subjectKind !== 'satellite' || !this.orbital)
|
||||
return false;
|
||||
const info = this.lastSubjectInfo;
|
||||
const swathKm = sensorSwathKm(info);
|
||||
if (swathKm === null || !Number.isFinite(info?.altitudeM)) return false;
|
||||
const zoom = sensorSwathZoom(
|
||||
info.altitudeM,
|
||||
swathKm,
|
||||
viewAspect(this.viewer),
|
||||
orbitalMaxZoomFor.call(this, info),
|
||||
);
|
||||
return zoom === null ? false : goToZoom.call(this, zoom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step the zoom with the mouse wheel over the picture: one step per notch,
|
||||
* trackpad scrolling summed to a step (`wheelZoomStep`). Only in orbit, and
|
||||
* never while a tool owns the pointer.
|
||||
* @param {WheelEvent} event
|
||||
* @returns {boolean} True when the zoom changed.
|
||||
*/
|
||||
export function onOrbitalWheel(event) {
|
||||
if (!this.active || this.subjectKind !== 'satellite' || !this.orbital)
|
||||
return false;
|
||||
if (!isPointerFree()) return false;
|
||||
const state = this.orbital;
|
||||
const { accumulator, step } = wheelZoomStep(
|
||||
state.wheel,
|
||||
event,
|
||||
performance.now(),
|
||||
);
|
||||
state.wheel = accumulator;
|
||||
if (!step) return false;
|
||||
const changed = stepOrbitalZoom.call(this, step);
|
||||
if (changed) event.preventDefault?.();
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** The sensor swath the view draws and the SENSOR step targets, km, or null. */
|
||||
function sensorSwathKm(info) {
|
||||
const swathKm = info?.sensor?.narrowestSwathKm;
|
||||
return Number.isFinite(swathKm) && swathKm > 0 ? swathKm : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The satellite's tightest zoom step at its live altitude. Only a swath
|
||||
* confirmed on a cited page opens steps past 8x.
|
||||
*/
|
||||
function orbitalMaxZoomFor(info) {
|
||||
return orbitalMaxZoom(
|
||||
info?.altitudeM,
|
||||
zoomableSwathKm(info?.sensor),
|
||||
viewAspect(this.viewer),
|
||||
);
|
||||
}
|
||||
|
||||
function viewAspect(viewer) {
|
||||
return Number.isFinite(viewer.camera?.frustum?.aspectRatio)
|
||||
? viewer.camera.frustum.aspectRatio
|
||||
: canvasAspect(viewer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the zoom to `next`, animated from the magnification shown now unless
|
||||
* reduced motion is on.
|
||||
|
||||
@@ -15,6 +15,8 @@ import CockpitView from './views/CockpitView.vue';
|
||||
import ContextView from './views/ContextView.vue';
|
||||
import IntelHudView from './views/IntelHudView.vue';
|
||||
import {
|
||||
ORBITAL_PREFETCH_INTERVAL_MS,
|
||||
ORBITAL_PREFETCH_MAX_TILES,
|
||||
ORBITAL_SETTLE_BLUR_PX,
|
||||
ORBITAL_SETTLE_MAX_MS,
|
||||
ORBITAL_ZOOM_TRANSITION_MS,
|
||||
@@ -23,6 +25,8 @@ import {
|
||||
orbitalBaseFovRad,
|
||||
} from '../cockpitOrbital.js';
|
||||
import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js';
|
||||
import { prefetchImageryAhead } from './cockpitOrbitalView.js';
|
||||
import { imageryLevelForView } from '../cockpitOrbital.js';
|
||||
import { claimPointer, releasePointer } from '../data/inputOwnership.js';
|
||||
|
||||
const ISS = Object.freeze({
|
||||
@@ -1012,6 +1016,302 @@ test('a tracked aircraft takes entry before a tracked satellite', async (t) => {
|
||||
assert.equal(store.presence.entryKind, 'aircraft');
|
||||
});
|
||||
|
||||
/** An imaging satellite with a narrow confirmed sensor swath. */
|
||||
const WORLDVIEW = Object.freeze({
|
||||
...ISS,
|
||||
id: '40115',
|
||||
noradId: 40115,
|
||||
name: 'WORLDVIEW-3 (WV-3)',
|
||||
classKey: 'visual',
|
||||
classLabel: 'IMAGING',
|
||||
altitudeM: 617_000,
|
||||
sensor: Object.freeze({
|
||||
label: 'WorldView-3',
|
||||
narrowestSwathKm: 13.1,
|
||||
swathConfirmed: true,
|
||||
}),
|
||||
});
|
||||
|
||||
/** A wide-area imager whose swath fits inside 8x. */
|
||||
const LANDSAT = Object.freeze({
|
||||
...WORLDVIEW,
|
||||
id: '49260',
|
||||
noradId: 49260,
|
||||
name: 'LANDSAT 9',
|
||||
altitudeM: 705_000,
|
||||
sensor: Object.freeze({
|
||||
label: 'Landsat 8 / Landsat 9',
|
||||
narrowestSwathKm: 185,
|
||||
swathConfirmed: true,
|
||||
}),
|
||||
});
|
||||
|
||||
test('an imaging satellite zooms past 8x as far as its sensor swath', async (t) => {
|
||||
const { $, cockpit, store } = await orbitAt(t, { satellite: WORLDVIEW });
|
||||
assert.equal(store.orbital.maxZoom, 64);
|
||||
assert.deepEqual(store.orbital.zoomSteps, [1, 2, 4, 8, 16, 32, 64]);
|
||||
for (let step = 0; step < 8; step += 1) press(cockpit, '+');
|
||||
assert.equal(store.orbital.zoom, 64, 'stops at the cap');
|
||||
await settle();
|
||||
assert.equal($('#cockpit-orbital-zoom-in').disabled, true);
|
||||
assert.match($('.cockpit-orbital-zoom').textContent, /64×/);
|
||||
// The swath, to scale: 13.1 km of a ~20 km view.
|
||||
assert.match(
|
||||
$('.cockpit-orbital-sensor-swath').textContent,
|
||||
/SENSOR SWATH 13\.1 KM/,
|
||||
);
|
||||
const width = parseFloat($('.cockpit-orbital-sensor-strip').style.width);
|
||||
assert.ok(width > 50 && width < 100, String(width));
|
||||
// The swath is narrower than the 64x view: no SENSOR step to take.
|
||||
assert.equal($('#cockpit-orbital-sensor'), null);
|
||||
});
|
||||
|
||||
test("the SENSOR step frames a wide sensor's swath", async (t) => {
|
||||
const { $, cockpit, store } = await orbitAt(t, { satellite: LANDSAT });
|
||||
assert.equal(store.orbital.maxZoom, 8, 'a wide swath adds no steps');
|
||||
await settle();
|
||||
const button = $('#cockpit-orbital-sensor');
|
||||
assert.ok(button, 'the SENSOR step is offered');
|
||||
assert.equal(button.getAttribute('aria-keyshortcuts'), null, 'no key');
|
||||
assert.doesNotMatch(button.title, /\(S\)/);
|
||||
button.click();
|
||||
const zoom = store.orbital.zoom;
|
||||
assert.ok(zoom > 4 && zoom < 8, String(zoom));
|
||||
assert.ok(Math.abs(store.orbital.footprintKm - 185) < 1, 'view = swath');
|
||||
await settle();
|
||||
assert.match($('.cockpit-orbital-zoom').textContent, /ZOOM \d\.\d×/);
|
||||
press(cockpit, '+');
|
||||
assert.equal(store.orbital.zoom, 8, 'on to the step above');
|
||||
press(cockpit, '-');
|
||||
press(cockpit, '-');
|
||||
assert.equal(store.orbital.zoom, 2);
|
||||
$('#cockpit-orbital-sensor').click();
|
||||
assert.equal(store.orbital.zoom, zoom, 'the button takes it again');
|
||||
});
|
||||
|
||||
test('off nadir the sensor strip stays and its swath reads approximate', async (t) => {
|
||||
const { $, cockpit, store } = await orbitAt(t, { satellite: LANDSAT });
|
||||
await settle();
|
||||
assert.equal(
|
||||
$('.cockpit-orbital-sensor-swath').textContent,
|
||||
'SENSOR SWATH 185 KM',
|
||||
);
|
||||
const nadirWidth = $('.cockpit-orbital-sensor-strip').style.width;
|
||||
press(cockpit, 'ArrowRight');
|
||||
press(cockpit, 'ArrowRight');
|
||||
await settle();
|
||||
assert.equal(store.orbital.look.offset, true);
|
||||
// Across the frame by 10°: 185 km / cos² 10° ≈ 190.8 km.
|
||||
const text = $('.cockpit-orbital-sensor-swath').textContent;
|
||||
const km = Number(text.match(/^SENSOR SWATH ≈ ([\d.]+) KM$/)[1]);
|
||||
assert.ok(Math.abs(km - 185 / Math.cos((10 * Math.PI) / 180) ** 2) < 0.1);
|
||||
assert.equal(
|
||||
$('.cockpit-orbital-sensor-strip').style.width,
|
||||
nadirWidth,
|
||||
'the strip keeps its share of the view',
|
||||
);
|
||||
assert.equal($('#cockpit-orbital-sensor'), null, 'no SENSOR step tilted');
|
||||
press(cockpit, 'Home');
|
||||
await settle();
|
||||
assert.equal(
|
||||
$('.cockpit-orbital-sensor-swath').textContent,
|
||||
'SENSOR SWATH 185 KM',
|
||||
);
|
||||
assert.ok($('#cockpit-orbital-sensor'));
|
||||
});
|
||||
|
||||
test('S does nothing in an orbital view, even with a SENSOR step', async (t) => {
|
||||
const { $, cockpit, store } = await orbitAt(t, { satellite: LANDSAT });
|
||||
await settle();
|
||||
assert.ok($('#cockpit-orbital-sensor'), 'the SENSOR step is offered');
|
||||
for (const key of ['s', 'S']) {
|
||||
const event = press(cockpit, key, { shiftKey: key === 'S' });
|
||||
assert.equal(event.defaultPrevented, false, key);
|
||||
assert.equal(store.orbital.zoom, 1, key);
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
document.querySelector('.cockpit-help')?.textContent ?? '',
|
||||
/SENSOR/,
|
||||
'no key hint',
|
||||
);
|
||||
});
|
||||
|
||||
/** A globe draping one Web Mercator imagery layer that records requests. */
|
||||
function imageryGlobe(viewer, { requestImage } = {}) {
|
||||
const requests = [];
|
||||
const requestObjects = [];
|
||||
const provider = {
|
||||
tilingScheme: new Cesium.WebMercatorTilingScheme(),
|
||||
tileWidth: 256,
|
||||
maximumLevel: 19,
|
||||
requestImage:
|
||||
requestImage ??
|
||||
((x, y, level, request) => {
|
||||
requests.push(`${level}/${x}/${y}`);
|
||||
requestObjects.push(request);
|
||||
return Promise.resolve({});
|
||||
}),
|
||||
};
|
||||
viewer.scene.globe = { show: true, tilesLoaded: true };
|
||||
viewer.scene.canvas = { clientWidth: 1600, clientHeight: 900 };
|
||||
viewer.imageryLayers = {
|
||||
length: 1,
|
||||
get: () => ({ show: true, alpha: 1, imageryProvider: provider }),
|
||||
};
|
||||
return { requests, requestObjects, provider };
|
||||
}
|
||||
|
||||
const WORLDVIEW_ON_TRACK = Object.freeze({ ...WORLDVIEW, groundTrackDeg: 52 });
|
||||
|
||||
test('from 16x the imagery ahead of the ground track is pre-loaded', async (t) => {
|
||||
const { cockpit, viewer, store, clock } = await orbitAt(t, {
|
||||
satellite: WORLDVIEW_ON_TRACK,
|
||||
});
|
||||
const { requests } = imageryGlobe(viewer);
|
||||
for (const zoom of [2, 4, 8]) {
|
||||
press(cockpit, '+');
|
||||
assert.equal(store.orbital.zoom, zoom);
|
||||
clock.advance(ORBITAL_PREFETCH_INTERVAL_MS);
|
||||
cockpit.update();
|
||||
}
|
||||
assert.deepEqual(requests, [], 'nothing below 16x');
|
||||
press(cockpit, '+');
|
||||
assert.equal(store.orbital.zoom, 16);
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
assert.ok(requests.length > 0, 'tiles ahead are asked for');
|
||||
assert.ok(requests.length <= ORBITAL_PREFETCH_MAX_TILES, 'bounded');
|
||||
assert.equal(new Set(requests.map((key) => key.split('/')[0])).size, 1);
|
||||
const first = requests.length;
|
||||
// Not again until the interval has passed, and never the same tile twice.
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
assert.equal(requests.length, first);
|
||||
clock.advance(ORBITAL_PREFETCH_INTERVAL_MS);
|
||||
cockpit.update();
|
||||
assert.equal(new Set(requests).size, requests.length, 'no repeats');
|
||||
// A tighter zoom needs a finer level.
|
||||
press(cockpit, '+');
|
||||
assert.equal(store.orbital.zoom, 32);
|
||||
clock.advance(ORBITAL_PREFETCH_INTERVAL_MS);
|
||||
cockpit.update();
|
||||
const levels = requests.map((key) => Number(key.split('/')[0]));
|
||||
assert.equal(levels.at(-1), levels[0] + 1);
|
||||
// Back below 16x, and after the exit: no more work.
|
||||
const before = requests.length;
|
||||
press(cockpit, '-');
|
||||
press(cockpit, '-');
|
||||
assert.equal(store.orbital.zoom, 8);
|
||||
clock.advance(ORBITAL_PREFETCH_INTERVAL_MS);
|
||||
cockpit.update();
|
||||
assert.equal(requests.length, before, 'stops below 16x');
|
||||
press(cockpit, '+');
|
||||
cockpit.exit();
|
||||
clock.advance(ORBITAL_PREFETCH_INTERVAL_MS);
|
||||
cockpit.update();
|
||||
assert.equal(
|
||||
prefetchImageryAhead.call(cockpit, WORLDVIEW_ON_TRACK, Infinity),
|
||||
0,
|
||||
);
|
||||
assert.equal(requests.length, before, 'stops after the exit');
|
||||
});
|
||||
|
||||
test('the pre-load skips a hidden globe and a provider that cannot fetch', async (t) => {
|
||||
const { cockpit, viewer, store, clock } = await orbitAt(t, {
|
||||
satellite: WORLDVIEW_ON_TRACK,
|
||||
});
|
||||
const { requests, provider } = imageryGlobe(viewer);
|
||||
for (let i = 0; i < 4; i += 1) press(cockpit, '+');
|
||||
assert.equal(store.orbital.zoom, 16);
|
||||
// Google 3D tiles drawn, the globe hidden: nothing to pre-load.
|
||||
viewer.scene.globe.show = false;
|
||||
clock.advance(ORBITAL_PREFETCH_INTERVAL_MS);
|
||||
cockpit.update();
|
||||
assert.deepEqual(requests, []);
|
||||
viewer.scene.globe.show = true;
|
||||
// No requestImage, or one that throws: no error, nothing remembered.
|
||||
delete provider.requestImage;
|
||||
clock.advance(ORBITAL_PREFETCH_INTERVAL_MS);
|
||||
assert.doesNotThrow(() => cockpit.update());
|
||||
provider.requestImage = () => {
|
||||
throw new Error('offline');
|
||||
};
|
||||
clock.advance(ORBITAL_PREFETCH_INTERVAL_MS);
|
||||
assert.doesNotThrow(() => cockpit.update());
|
||||
assert.equal(store.orbital.zoom, 16, 'the view carries on');
|
||||
});
|
||||
|
||||
test('an unconfirmed sensor swath is drawn but adds no zoom steps', async (t) => {
|
||||
const { store } = await orbitAt(t, {
|
||||
satellite: {
|
||||
...WORLDVIEW,
|
||||
sensor: {
|
||||
label: 'SuperView Neo-1',
|
||||
narrowestSwathKm: 12,
|
||||
swathConfirmed: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(store.orbital.maxZoom, 8);
|
||||
assert.equal(store.orbital.sensor.swathKm, 12);
|
||||
});
|
||||
|
||||
test('the wheel steps the orbital zoom one notch at a time', async (t) => {
|
||||
const { cockpit, store, clock } = await orbitAt(t, { satellite: WORLDVIEW });
|
||||
const wheel = (deltaY) => {
|
||||
const event = {
|
||||
deltaY,
|
||||
deltaMode: 0,
|
||||
defaultPrevented: false,
|
||||
preventDefault() {
|
||||
this.defaultPrevented = true;
|
||||
},
|
||||
};
|
||||
cockpit.onOrbitalWheel(event);
|
||||
return event;
|
||||
};
|
||||
assert.equal(wheel(-100).defaultPrevented, true);
|
||||
assert.equal(store.orbital.zoom, 2);
|
||||
// The same notch's trailing events do nothing.
|
||||
assert.equal(wheel(-100).defaultPrevented, false);
|
||||
assert.equal(store.orbital.zoom, 2);
|
||||
clock.advance(300);
|
||||
wheel(-100);
|
||||
assert.equal(store.orbital.zoom, 4);
|
||||
clock.advance(300);
|
||||
wheel(120);
|
||||
assert.equal(store.orbital.zoom, 2, 'toward the viewer zooms out');
|
||||
// A tool that owns the pointer keeps the wheel.
|
||||
const lease = claimPointer('measure');
|
||||
t.after(() => releasePointer(lease));
|
||||
clock.advance(300);
|
||||
wheel(-100);
|
||||
assert.equal(store.orbital.zoom, 2);
|
||||
releasePointer(lease);
|
||||
cockpit.exit();
|
||||
await settle();
|
||||
assert.equal(cockpit.onOrbitalWheel({ deltaY: -100 }), false, 'orbit only');
|
||||
});
|
||||
|
||||
test('the orbital view reads the TLE age on each HUD publish', async (t) => {
|
||||
const HOUR = 3_600_000;
|
||||
const { cockpit, store, clock } = await orbitAt(t, {
|
||||
satellite: {
|
||||
...ISS,
|
||||
tle: {
|
||||
line1: '1 25544U',
|
||||
line2: '2 25544',
|
||||
epochMs: START_MS - 13.5 * HOUR,
|
||||
},
|
||||
},
|
||||
});
|
||||
const ageH = () => store.orbital.tleAgeMs / HOUR;
|
||||
assert.ok(Math.abs(ageH() - 13.5) < 0.01, String(ageH()));
|
||||
clock.advance(HOUR);
|
||||
cockpit.update();
|
||||
assert.ok(Math.abs(ageH() - 14.5) < 0.01, 'kept current by the HUD cadence');
|
||||
});
|
||||
|
||||
/** The ISS climbing north-east along its ground track. */
|
||||
const ISS_ON_TRACK = Object.freeze({ ...ISS, groundTrackDeg: 52 });
|
||||
|
||||
@@ -1059,8 +1359,25 @@ test('a satellite with no ground track stays north-up', async (t) => {
|
||||
assert.match($('.cockpit-orbital-north').textContent, /NORTH UP/);
|
||||
});
|
||||
|
||||
test('a narrower window pulls the zoom back to the new top step', async (t) => {
|
||||
const { cockpit, viewer, store, clock } = await orbitAt(t, {
|
||||
satellite: WORLDVIEW,
|
||||
});
|
||||
for (let step = 0; step < 8; step += 1) press(cockpit, '+');
|
||||
assert.equal(store.orbital.zoom, 64);
|
||||
// A portrait window: the same zoom now shows finer ground than the sensor
|
||||
// images, so the top step drops.
|
||||
viewer.camera.frustum.aspectRatio = 0.5;
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
await settle();
|
||||
assert.ok(store.orbital.maxZoom < 64, String(store.orbital.maxZoom));
|
||||
assert.equal(store.orbital.zoom, store.orbital.maxZoom);
|
||||
assert.ok(store.orbital.zoomSteps.includes(store.orbital.zoom));
|
||||
});
|
||||
|
||||
test('a zoom button that turns off hands focus to the other one', async (t) => {
|
||||
const { $, cockpit } = await orbitAt(t);
|
||||
const { $, cockpit } = await orbitAt(t, { satellite: WORLDVIEW });
|
||||
await settle();
|
||||
$('#cockpit-orbital-zoom-in').focus();
|
||||
for (let step = 0; step < 8; step += 1) press(cockpit, '+');
|
||||
@@ -1074,6 +1391,16 @@ test('a zoom button that turns off hands focus to the other one', async (t) => {
|
||||
assert.match($('#cockpit-orbital-zoom-in').title, /\(\+\)/);
|
||||
});
|
||||
|
||||
test('SENSOR hands focus to NADIR when a tilt takes it away', async (t) => {
|
||||
const { $, cockpit } = await orbitAt(t, { satellite: LANDSAT });
|
||||
await settle();
|
||||
$('#cockpit-orbital-sensor').focus();
|
||||
press(cockpit, 'ArrowUp');
|
||||
await settle();
|
||||
assert.equal($('#cockpit-orbital-sensor'), null);
|
||||
assert.equal(document.activeElement?.id, 'cockpit-orbital-nadir');
|
||||
});
|
||||
|
||||
test('the readout says OFF-NADIR while the view is tilted', async (t) => {
|
||||
const { $, cockpit } = await orbitAt(t);
|
||||
await settle();
|
||||
@@ -1088,3 +1415,45 @@ test('the readout says OFF-NADIR while the view is tilted', async (t) => {
|
||||
'ORBITAL VIEW · OFF-NADIR',
|
||||
);
|
||||
});
|
||||
|
||||
test('the pre-load is throttled, at the level Cesium draws, and waits out a tilt', async (t) => {
|
||||
const { cockpit, viewer, store, clock } = await orbitAt(t, {
|
||||
satellite: WORLDVIEW_ON_TRACK,
|
||||
});
|
||||
const { requests, requestObjects } = imageryGlobe(viewer);
|
||||
for (let i = 0; i < 4; i += 1) press(cockpit, '+');
|
||||
assert.equal(store.orbital.zoom, 16);
|
||||
// Tilted off nadir the view looks away from the track: nothing asked.
|
||||
press(cockpit, 'ArrowRight');
|
||||
clock.advance(ORBITAL_PREFETCH_INTERVAL_MS);
|
||||
cockpit.update();
|
||||
assert.deepEqual(requests, [], 'nothing while tilted');
|
||||
press(cockpit, 'Home');
|
||||
for (let frame = 0; frame < 30; frame += 1) {
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
}
|
||||
assert.ok(requests.length > 0, 'back at nadir it pre-loads');
|
||||
// Every request may be declined while Cesium is busy with the screen.
|
||||
for (const request of requestObjects) {
|
||||
assert.ok(request instanceof Cesium.Request);
|
||||
assert.equal(request.throttle, true);
|
||||
assert.equal(request.throttleByServer, true);
|
||||
assert.equal(
|
||||
request.priority,
|
||||
Number.MAX_VALUE,
|
||||
'never ahead of the screen',
|
||||
);
|
||||
assert.equal(request.type, Cesium.RequestType.IMAGERY);
|
||||
}
|
||||
// The level Cesium's own selection takes for this view.
|
||||
const fov = fovForZoom(orbitalBaseFovRad(WORLDVIEW.altitudeM), 16);
|
||||
const metresPerPixel =
|
||||
(WORLDVIEW.altitudeM * 2 * Math.tan(fov / 2)) /
|
||||
viewer.scene.canvas.clientHeight;
|
||||
const expected = imageryLevelForView(new Cesium.WebMercatorTilingScheme(), {
|
||||
metresPerPixel,
|
||||
latitudeDeg: WORLDVIEW.latitude,
|
||||
});
|
||||
assert.equal(Number(requests[0].split('/')[0]), expected);
|
||||
});
|
||||
@@ -156,6 +156,22 @@ export function formatTleEpochUtc(epochMs) {
|
||||
return `${iso.slice(0, 10)} ${iso.slice(11, 16)}Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* How old a TLE is, in the identifier line's compact units: under an hour,
|
||||
* whole hours under a day, then whole days. A TLE's propagation error grows
|
||||
* with its age, so the age is shown beside the epoch.
|
||||
* @param {number|null|undefined} ageMs Now minus the TLE epoch.
|
||||
* @returns {{unit: 'under-hour'}|{unit: 'hours'|'days', value: number}|null}
|
||||
* Null without a usable age.
|
||||
*/
|
||||
export function tleAgeParts(ageMs) {
|
||||
if (!Number.isFinite(ageMs)) return null;
|
||||
const hours = Math.max(0, ageMs) / 3_600_000;
|
||||
if (hours < 1) return { unit: 'under-hour' };
|
||||
if (hours < 24) return { unit: 'hours', value: Math.floor(hours) };
|
||||
return { unit: 'days', value: Math.floor(hours / 24) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Cockpit entry for each subject it can ride: an aircraft's cockpit, a
|
||||
* vessel's bridge or a satellite's orbit. Both entry buttons (Contacts and
|
||||
|
||||
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
formatCockpitDistance,
|
||||
formatCockpitWindDirection,
|
||||
tleAgeParts,
|
||||
} from './cockpitPresentation.js';
|
||||
|
||||
test('wind just west of north reads 0°, not 360°', () => {
|
||||
@@ -16,3 +17,18 @@ test('a cockpit distance that rounds to 10.0 km drops the decimal', () => {
|
||||
assert.match(formatCockpitDistance(9949), /^9\.9\s?KM$/);
|
||||
assert.match(formatCockpitDistance(9960), /^10\s?KM$/);
|
||||
});
|
||||
|
||||
test('a TLE age reads under an hour, then whole hours, then whole days', () => {
|
||||
const HOUR = 3_600_000;
|
||||
assert.deepEqual(tleAgeParts(0), { unit: 'under-hour' });
|
||||
assert.deepEqual(tleAgeParts(59 * 60_000), { unit: 'under-hour' });
|
||||
assert.deepEqual(tleAgeParts(HOUR), { unit: 'hours', value: 1 });
|
||||
assert.deepEqual(tleAgeParts(13.9 * HOUR), { unit: 'hours', value: 13 });
|
||||
assert.deepEqual(tleAgeParts(24 * HOUR - 1), { unit: 'hours', value: 23 });
|
||||
assert.deepEqual(tleAgeParts(24 * HOUR), { unit: 'days', value: 1 });
|
||||
assert.deepEqual(tleAgeParts(50 * HOUR), { unit: 'days', value: 2 });
|
||||
// A clock a little behind the epoch is still fresh, not negative.
|
||||
assert.deepEqual(tleAgeParts(-5 * 60_000), { unit: 'under-hour' });
|
||||
assert.equal(tleAgeParts(null), null);
|
||||
assert.equal(tleAgeParts(Number.NaN), null);
|
||||
});
|
||||
@@ -91,11 +91,13 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
|
||||
* The orbital view's HUD: null outside a satellite subject. Shape:
|
||||
* `{ name, classLabel, altitudeM, velocityMps|null, latitude, longitude,
|
||||
* zoom, zoomSteps: number[], maxZoom, footprintKm|null,
|
||||
* sensor: { label|null, swathKm, stripFraction|null, zoom|null }|null,
|
||||
* transition: { active, blurPx, lockTick },
|
||||
* look: { offset, readout, swathScale, swathKm|null }, trackUp, northDeg,
|
||||
* place: { kind: 'country'|'sea'|'ocean', name, countryCode,
|
||||
* division?|null, near?|null }|null|undefined,
|
||||
* noradId|null, cosparId|null, tle: { line1, line2, epochMs }|null,
|
||||
* tleAgeMs|null (now minus the TLE epoch, read on each publish),
|
||||
* owner: { countryCode|null, label|null }|null, operator|null }` where
|
||||
* `place` is what the sub-satellite point is over (undefined until the
|
||||
* first offline lookup lands, null when nothing named holds it; see
|
||||
@@ -103,8 +105,12 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
|
||||
* layer and are null when unknown, `classLabel` is
|
||||
* already localized (the satellite layer's class label, e.g. "NAV · GPS"),
|
||||
* `velocityMps` is the orbital (inertial) speed, latitude/longitude are the
|
||||
* sub-satellite point in degrees, `zoom` is one of `zoomSteps` (1, 2, 4
|
||||
* and 8, the tightest being `maxZoom`), `footprintKm` is the nadir ground width across the frame at that zoom
|
||||
* sub-satellite point in degrees, `zoom` is one of `zoomSteps` (1, 2, 4, 8,
|
||||
* and up to 128 for an imaging satellite with a known sensor: `maxZoom`) or
|
||||
* the SENSOR step's zoom between them, `sensor` is the imaging sensor's
|
||||
* narrowest swath with the fraction of the view it spans (kept off nadir)
|
||||
* and the zoom that frames it (null when outside 1x..maxZoom),
|
||||
* `footprintKm` is the nadir ground width across the frame at that zoom
|
||||
* (null while the view is tilted off nadir), `transition` is a zoom step in
|
||||
* flight: `active` while the field of view moves, `blurPx` the focus-pull
|
||||
* blur for the picture, `lockTick` true briefly once the step settles (see
|
||||
|
||||
@@ -354,6 +354,15 @@
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* With a sensor's swath and step the strip is longer: it may wrap on a
|
||||
narrow screen rather than run off it. */
|
||||
@media (max-width: 720px) {
|
||||
.cockpit-orbital-optics {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
}
|
||||
.cockpit-orbital-zoom-button {
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
@@ -497,3 +506,41 @@
|
||||
body.cockpit-orbit #view-switcher button {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* The imaging sensor's narrowest swath, to scale across the view's width. The
|
||||
view is track-up, so the swath runs top to bottom along the ground track. */
|
||||
.cockpit-orbital-sensor-strip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
min-width: 2px;
|
||||
border-inline: 1px dashed var(--orbital-line);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--cockpit-accent, #00dcff) 6%,
|
||||
transparent
|
||||
);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.cockpit-orbital-sensor-swath {
|
||||
color: var(--cockpit-accent, #00dcff);
|
||||
}
|
||||
.cockpit-orbital-sensor-step {
|
||||
padding: 3px 8px;
|
||||
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;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.cockpit-orbital-sensor-step:hover,
|
||||
.cockpit-orbital-sensor-step:focus-visible {
|
||||
border-color: var(--cockpit-accent, #00dcff);
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { formatNumber, getLocale, t } from '../../../i18n/index.js';
|
||||
import {
|
||||
formatCockpitCoordinate,
|
||||
formatTleEpochUtc,
|
||||
tleAgeParts,
|
||||
} from '../../cockpitPresentation.js';
|
||||
import {
|
||||
loadFlagUrl,
|
||||
@@ -52,8 +53,11 @@ const subPointText = computed(() => {
|
||||
|
||||
const zoomSteps = computed(() => orbital.value?.zoomSteps || [1]);
|
||||
const zoom = computed(() => orbital.value?.zoom ?? zoomSteps.value[0]);
|
||||
// The SENSOR step lands between ladder steps, e.g. 5.7×.
|
||||
const zoomText = computed(() =>
|
||||
t('ZOOM {zoom}×', { zoom: formatNumber(zoom.value) }),
|
||||
t('ZOOM {zoom}×', {
|
||||
zoom: formatNumber(zoom.value, { maximumFractionDigits: 1 }),
|
||||
}),
|
||||
);
|
||||
const maxZoom = computed(
|
||||
() => orbital.value?.maxZoom ?? Math.max(...zoomSteps.value),
|
||||
@@ -61,6 +65,11 @@ const maxZoom = computed(
|
||||
const atWidest = computed(() => zoom.value <= Math.min(...zoomSteps.value));
|
||||
const atTightest = computed(() => zoom.value >= maxZoom.value);
|
||||
|
||||
// The imaging sensor's swath: a strip drawn to scale across the view, its
|
||||
// width as a readout, and the SENSOR step that zooms the view to it. Off
|
||||
// nadir the strip stays, the readout scales as the view's swath does (≈),
|
||||
// and the SENSOR step, a nadir framing, is hidden.
|
||||
const sensor = computed(() => orbital.value?.sensor || null);
|
||||
// Track-up turns the frame to the ground track; north's pointer turns back.
|
||||
const trackUp = computed(() => orbital.value?.trackUp === true);
|
||||
const northPointerStyle = computed(() => {
|
||||
@@ -77,11 +86,42 @@ const northLetterStyle = computed(() => {
|
||||
: null;
|
||||
});
|
||||
|
||||
// A focused zoom button that turns off at the end of its range hands focus
|
||||
// on instead of dropping it to the page. The watchers run before the
|
||||
// re-render, while it still has focus.
|
||||
const sensorStripStyle = computed(() => {
|
||||
const fraction = sensor.value?.stripFraction;
|
||||
return finite(fraction) && fraction > 0
|
||||
? { width: `${(fraction * 100).toFixed(3)}%` }
|
||||
: null;
|
||||
});
|
||||
const sensorText = computed(() => {
|
||||
const km = sensor.value?.swathKm;
|
||||
if (!finite(km)) return '';
|
||||
const look = orbital.value?.look;
|
||||
if (look?.offset === true) {
|
||||
const scale = look.swathScale;
|
||||
if (!finite(scale)) return '';
|
||||
return t('SENSOR SWATH ≈ {value} KM', {
|
||||
value: formatNumber(km * scale, { maximumFractionDigits: 1 }),
|
||||
});
|
||||
}
|
||||
// As published: 13.1 km, 185 km.
|
||||
return t('SENSOR SWATH {value} KM', {
|
||||
value: formatNumber(km, { maximumFractionDigits: 1 }),
|
||||
});
|
||||
});
|
||||
const sensorStepShown = computed(
|
||||
() =>
|
||||
finite(sensor.value?.zoom) &&
|
||||
finite(orbital.value?.footprintKm) &&
|
||||
orbital.value?.look?.offset !== true,
|
||||
);
|
||||
|
||||
// A focused control that turns off (a zoom button at the end of its range,
|
||||
// SENSOR as the view tilts away) hands focus on instead of dropping it to the
|
||||
// page. The watchers run before the re-render, while it still has focus.
|
||||
const zoomInButton = ref(null);
|
||||
const zoomOutButton = ref(null);
|
||||
const sensorButton = ref(null);
|
||||
const nadirButton = ref(null);
|
||||
const handFocus = (from, to) => {
|
||||
if (!from || document.activeElement !== from) return;
|
||||
nextTick(() =>
|
||||
@@ -100,6 +140,11 @@ watch(
|
||||
(now) => now && handFocus(zoomOutButton.value, () => zoomInButton.value),
|
||||
{ flush: 'pre' },
|
||||
);
|
||||
watch(
|
||||
sensorStepShown,
|
||||
(shown) => !shown && handFocus(sensorButton.value, () => nadirButton.value),
|
||||
{ flush: 'pre' },
|
||||
);
|
||||
|
||||
// At nadir the footprint; tilted, the approximate width at the view's
|
||||
// centre, marked ≈.
|
||||
@@ -185,6 +230,12 @@ const identParts = computed(() => {
|
||||
if (o.cosparId) parts.push(o.cosparId);
|
||||
const epoch = formatTleEpochUtc(o.tle?.epochMs);
|
||||
if (epoch) parts.push(t('EPOCH {time}', { time: epoch }));
|
||||
const age = tleAgeParts(o.tleAgeMs);
|
||||
if (age?.unit === 'under-hour') parts.push(t('<1 H OLD'));
|
||||
else if (age?.unit === 'hours')
|
||||
parts.push(t('{value} H OLD', { value: formatNumber(age.value) }));
|
||||
else if (age?.unit === 'days')
|
||||
parts.push(t('{value} D OLD', { value: formatNumber(age.value) }));
|
||||
return parts;
|
||||
});
|
||||
const identText = computed(() => identParts.value.join(' · '));
|
||||
@@ -229,6 +280,8 @@ const lensStyle = computed(() => {
|
||||
<span v-if="trackUp" class="cockpit-orbital-north-letter" :style="northLetterStyle">{{ t('N') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- The imaging sensor's swath, to scale across the view. -->
|
||||
<div v-if="sensorStripStyle" class="cockpit-orbital-sensor-strip" :style="sensorStripStyle" aria-hidden="true"></div>
|
||||
<!-- Under the vision selector: where the ground track is. -->
|
||||
<p v-if="placeText" id="cockpit-orbital-over" class="cockpit-orbital-over"><span>{{ t('CURRENTLY OVER') }}</span> <strong>{{ placeText }}</strong></p>
|
||||
<div class="cockpit-orbital-readout">
|
||||
@@ -265,7 +318,7 @@ const lensStyle = computed(() => {
|
||||
<!-- Shown only while the view is tilted off nadir: where it looks and the way back. -->
|
||||
<div v-if="look.offset" class="cockpit-orbital-look">
|
||||
<span id="cockpit-orbital-look-readout" class="cockpit-orbital-look-readout" aria-live="polite">{{ look.readout }}</span>
|
||||
<button id="cockpit-orbital-nadir" 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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="cockpit-orbital-optics">
|
||||
@@ -273,6 +326,8 @@ const lensStyle = computed(() => {
|
||||
<span class="cockpit-orbital-zoom" aria-live="polite">{{ zoomText }}</span>
|
||||
<button id="cockpit-orbital-zoom-in" ref="zoomInButton" type="button" class="cockpit-orbital-zoom-button" :disabled="atTightest" :aria-label="t('Zoom in')" :title="t('Zoom in (+)')" aria-keyshortcuts="+ =">+</button>
|
||||
<span v-if="swathText" class="cockpit-orbital-swath">{{ swathText }}</span>
|
||||
<span v-if="sensorText" class="cockpit-orbital-swath cockpit-orbital-sensor-swath">{{ sensorText }}</span>
|
||||
<button v-if="sensorStepShown" id="cockpit-orbital-sensor" ref="sensorButton" type="button" class="cockpit-orbital-sensor-step" :aria-label="t('Zoom to the imaging sensor swath')" :title="t('Zoom to the imaging sensor swath')">{{ t('SENSOR') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
Reference in new issue
Block a user