mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(weather): add globe wind, observed weather and cyclone context (#617)
Wind (NOAA GFS and ECMWF IFS: animated particles, colour field, a reading at map centre), observed weather (NOAA MRMS radar, GOES and NESDIS infrared clouds, lightning density) on one shared history timeline, NHC and CPHC cyclone advisories with tracks and cones, and a WEATHER panel in the right rail. On 3D Tiles the observed products render as raised shells with a sharper window around the view. Co-authored-by: Gustavo Beneduzi <[email protected]> Co-authored-by: Daniel Slay <[email protected]> Co-authored-by: Rehaan Delmotra <[email protected]> Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
180 files changed
+30645
-434
No files matched your search
@@ -8,6 +8,7 @@ import { createTesting } from './testing.js';
|
||||
import { createInteraction } from './interaction.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createRecords } from './records.js';
|
||||
import { createState } from './state.js';
|
||||
|
||||
/** Construct one layer with its own scene state and supplied application services. */
|
||||
@@ -21,6 +22,7 @@ export function createSatellitesLayer({ services, source }) {
|
||||
parts.catalog = createCatalog(context);
|
||||
parts.labels = createLabels(context);
|
||||
parts.orbits = createOrbits(context);
|
||||
parts.records = createRecords(context);
|
||||
parts.rendering = createRendering(context);
|
||||
parts.tracking = createTracking(context);
|
||||
parts.testing = createTesting(context);
|
||||
@@ -63,6 +65,9 @@ export function createSatellitesLayer({ services, source }) {
|
||||
applySatellitePointFocusDeemphasis:
|
||||
parts.rendering.applySatellitePointFocusDeemphasis,
|
||||
getNextIssPass: parts.orbits.getNextIssPass,
|
||||
getNextSatellitePass: parts.orbits.getNextSatellitePass,
|
||||
resolveSatelliteForPass: parts.orbits.resolveSatelliteForPass,
|
||||
getAnalystRecords: parts.records.getAnalystRecords,
|
||||
scoreSatelliteNameMatch: parts.orbits.scoreSatelliteNameMatch,
|
||||
findSatelliteOrbitTrackInTle: parts.orbits.findSatelliteOrbitTrackInTle,
|
||||
getSatelliteOrbitTrack: parts.orbits.getSatelliteOrbitTrack,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
degreesLat,
|
||||
twoline2satrec,
|
||||
} from 'satellite.js';
|
||||
import { findNextIssPass } from '../../data/issPass.js';
|
||||
import { findNextSatellitePass } from '../../data/satellitePass.js';
|
||||
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
|
||||
|
||||
export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
@@ -136,22 +136,98 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* Next ISS pass for an observer. Requires the catalog to have loaded (the
|
||||
* satellites layer enabled at least once this session).
|
||||
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number}}}
|
||||
* @param {Object} options
|
||||
* @param {number} options.latDeg Observer latitude
|
||||
* @param {number} options.lonDeg Observer longitude
|
||||
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
|
||||
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
|
||||
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:{riseMs:number,setMs:number,maxElevDeg:number,maxElevMs:number,riseAzDeg:number,visible?:boolean,sunlit?:boolean,observerDark?:boolean}}}
|
||||
*/
|
||||
|
||||
function getNextIssPass({ latDeg, lonDeg, minElevDeg = 10 }) {
|
||||
const sat = layerState._catalog.get(ISS_NORAD);
|
||||
function getNextIssPass({
|
||||
latDeg,
|
||||
lonDeg,
|
||||
minElevDeg = 10,
|
||||
requireVisible = false,
|
||||
fromMs = Date.now(),
|
||||
}) {
|
||||
return getNextSatellitePass(ISS_NORAD, {
|
||||
latDeg,
|
||||
lonDeg,
|
||||
minElevDeg,
|
||||
requireVisible,
|
||||
fromMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Next pass of any catalog satellite for an observer.
|
||||
* @param {number} noradId NORAD satellite catalog ID
|
||||
* @param {Object} options
|
||||
* @param {number} options.latDeg Observer latitude
|
||||
* @param {number} options.lonDeg Observer longitude
|
||||
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
|
||||
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
|
||||
* @param {number} [options.fromMs=Date.now()] Search start time in UTC milliseconds
|
||||
* @returns {{status:'no-tle'}|{status:'none'}|{status:'ok', pass:Object}}
|
||||
*/
|
||||
|
||||
function getNextSatellitePass(
|
||||
noradId,
|
||||
{
|
||||
latDeg,
|
||||
lonDeg,
|
||||
minElevDeg = 10,
|
||||
requireVisible = false,
|
||||
fromMs = Date.now(),
|
||||
},
|
||||
) {
|
||||
const id = Number(noradId);
|
||||
if (!Number.isSafeInteger(id) || id <= 0) return { status: 'no-tle' };
|
||||
const sat = layerState._catalog.get(id);
|
||||
if (!sat || !sat.satrec) return { status: 'no-tle' };
|
||||
const pass = findNextIssPass({
|
||||
const pass = findNextSatellitePass({
|
||||
satrec: sat.satrec,
|
||||
latDeg,
|
||||
lonDeg,
|
||||
fromMs: Date.now(),
|
||||
fromMs,
|
||||
minElevDeg,
|
||||
requireVisible,
|
||||
});
|
||||
return pass ? { status: 'ok', pass } : { status: 'none' };
|
||||
}
|
||||
|
||||
/** Resolve only loaded catalog identities; ambiguous names never pick a first row. */
|
||||
function resolveSatelliteForPass(query) {
|
||||
const text = String(query ?? '').trim();
|
||||
if (!text || text.length > 120) return { status: 'not-found' };
|
||||
const exactId = /^\d+$/.test(text) ? Number(text) : null;
|
||||
if (exactId !== null) {
|
||||
const sat = layerState._catalog.get(exactId);
|
||||
return sat?.satrec
|
||||
? { status: 'ok', noradId: exactId, name: sat.name }
|
||||
: { status: 'not-found' };
|
||||
}
|
||||
const q = text.toLowerCase();
|
||||
const rows = [...layerState._catalog].filter(([, sat]) => sat.satrec);
|
||||
const exact = rows.filter(
|
||||
([, sat]) => String(sat.name).toLowerCase() === q,
|
||||
);
|
||||
const matches = exact.length
|
||||
? exact
|
||||
: rows.filter(([, sat]) => String(sat.name).toLowerCase().includes(q));
|
||||
if (!matches.length) return { status: 'not-found' };
|
||||
if (matches.length > 1)
|
||||
return {
|
||||
status: 'ambiguous',
|
||||
candidates: matches
|
||||
.slice(0, 8)
|
||||
.map(([noradId, sat]) => ({ noradId, name: sat.name })),
|
||||
totalMatches: matches.length,
|
||||
};
|
||||
return { status: 'ok', noradId: matches[0][0], name: matches[0][1].name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a mission-to-catalog name match. Compact identifier containment handles
|
||||
* names such as "Sirius SXM-11" → "SXM-11", while weak generic matches such as
|
||||
@@ -316,6 +392,8 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
orbitalPeriodSeconds,
|
||||
computeOrbitPath,
|
||||
getNextIssPass,
|
||||
getNextSatellitePass,
|
||||
resolveSatelliteForPass,
|
||||
scoreSatelliteNameMatch,
|
||||
internationalDesignatorYear,
|
||||
lookupTleEntries,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { satelliteClassLabel } from '../../data/satelliteClass.js';
|
||||
import { ISS_NORAD } from './policy.js';
|
||||
|
||||
/**
|
||||
* Map one CelesTrak catalog satellite to a JSON-safe analyst record
|
||||
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
|
||||
* fields are null, never NaN/undefined. `id` is the display name (same
|
||||
* convention as vessels); `noradId` is the track_entity key.
|
||||
* @param {Object|null|undefined} raw - {noradId, name, group, lat, lon, altitudeM, speedMps}.
|
||||
* @returns {{id: string, noradId: string|null, name: string|null,
|
||||
* lat: number|null, lon: number|null, altitudeM: number|null,
|
||||
* speedMps: number|null, satelliteClass: string|null, group: string|null}}
|
||||
*/
|
||||
export function mapAnalystRecord(raw) {
|
||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||
const text = (v) => {
|
||||
const t = String(v ?? '').trim();
|
||||
return t || null;
|
||||
};
|
||||
const noradNum =
|
||||
raw?.noradId == null || raw.noradId === '' ? NaN : Number(raw.noradId);
|
||||
const noradId = Number.isFinite(noradNum)
|
||||
? String(Math.trunc(noradNum))
|
||||
: text(raw?.noradId);
|
||||
const name = text(raw?.name);
|
||||
const group = text(raw?.group);
|
||||
const isIss = noradNum === ISS_NORAD;
|
||||
return {
|
||||
id: name || (noradId ? `SAT-${noradId}` : 'SAT-00000'),
|
||||
noradId,
|
||||
name,
|
||||
lat: num(raw?.lat),
|
||||
lon: num(raw?.lon),
|
||||
altitudeM: num(raw?.altitudeM),
|
||||
speedMps: num(raw?.speedMps),
|
||||
// Empty records stay class-less; the class table's fallback is VISUAL and
|
||||
// must not leak onto a row that has no satellite identity.
|
||||
satelliteClass:
|
||||
noradId || group ? satelliteClassLabel(group, { isIss }) : null,
|
||||
group,
|
||||
};
|
||||
}
|
||||
|
||||
/** Create on-demand analyst snapshots owned by one layer instance. */
|
||||
export function createRecords({ state, parts }) {
|
||||
function analystGeo(noradId, sat, now) {
|
||||
const pos = sat?.satrec
|
||||
? parts.orbits.propagatePosition(sat.satrec, now)
|
||||
: null;
|
||||
if (
|
||||
pos &&
|
||||
Number.isFinite(pos.latitude) &&
|
||||
Number.isFinite(pos.longitude)
|
||||
) {
|
||||
return {
|
||||
lat: pos.latitude,
|
||||
lon: pos.longitude,
|
||||
altitudeM: pos.altitude,
|
||||
speedMps: Number.isFinite(pos.speedMps) ? pos.speedMps : null,
|
||||
};
|
||||
}
|
||||
const point = state._points.get(noradId);
|
||||
if (!point?.position) return null;
|
||||
const carto = Cesium.Cartographic.fromCartesian(point.position);
|
||||
if (!carto) return null;
|
||||
return {
|
||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||
altitudeM: carto.height,
|
||||
speedMps: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getAnalystRecords(maxCount = 2000) {
|
||||
if (!state._enabled || !state._catalog.size) return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
: 2000;
|
||||
const now = new Date();
|
||||
const result = [];
|
||||
const emit = (noradId, sat) => {
|
||||
if (result.length >= limit) return false;
|
||||
const pos = analystGeo(noradId, sat, now);
|
||||
if (!pos) return true;
|
||||
result.push(
|
||||
mapAnalystRecord({
|
||||
noradId,
|
||||
name: sat?.name,
|
||||
group: sat?.group,
|
||||
lat: pos.lat,
|
||||
lon: pos.lon,
|
||||
altitudeM: pos.altitudeM,
|
||||
speedMps: pos.speedMps,
|
||||
}),
|
||||
);
|
||||
return result.length < limit;
|
||||
};
|
||||
for (const [noradId, sat] of state._catalog) {
|
||||
if (sat?.group === 'dense') continue;
|
||||
if (!emit(noradId, sat)) break;
|
||||
}
|
||||
if (result.length < limit) {
|
||||
for (const [noradId, sat] of state._catalog) {
|
||||
if (sat?.group !== 'dense') continue;
|
||||
if (!emit(noradId, sat)) break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return { getAnalystRecords };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { createSatelliteSource } from './source.js';
|
||||
import { createSatellitesLayer } from './index.js';
|
||||
|
||||
@@ -56,3 +57,110 @@ test('satellite factories keep control state separate and construct without requ
|
||||
assert.equal(second.getParams().showPoints, true);
|
||||
assert.notEqual(first.getStats(), second.getStats());
|
||||
});
|
||||
|
||||
test('satellite pass prediction methods report no-tle on cold catalog and coerce IDs', () => {
|
||||
const source = { readGroup() {} };
|
||||
const services = Object.fromEntries(
|
||||
[
|
||||
'picking',
|
||||
'focus',
|
||||
'readout',
|
||||
'overlays',
|
||||
'context',
|
||||
'render',
|
||||
'layerState',
|
||||
].map((key) => [key, {}]),
|
||||
);
|
||||
services.layerState.isExplicitLayerStateOrigin = () => false;
|
||||
const layer = createSatellitesLayer({ source, services });
|
||||
|
||||
// Cold catalog (no satellites loaded yet)
|
||||
assert.deepEqual(layer.getNextIssPass({ latDeg: 30, lonDeg: -97 }), {
|
||||
status: 'no-tle',
|
||||
});
|
||||
assert.deepEqual(
|
||||
layer.getNextSatellitePass(25544, { latDeg: 30, lonDeg: -97 }),
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
|
||||
// String NORAD ID coercion
|
||||
assert.deepEqual(
|
||||
layer.getNextSatellitePass('25544', { latDeg: 30, lonDeg: -97 }),
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
|
||||
// Invalid / non-finite ID handling
|
||||
assert.deepEqual(
|
||||
layer.getNextSatellitePass(NaN, { latDeg: 30, lonDeg: -97 }),
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
layer.getNextSatellitePass(null, { latDeg: 30, lonDeg: -97 }),
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
|
||||
// Populated catalog: verify numeric and string coercion succeed
|
||||
const L1 =
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
const L2 =
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537';
|
||||
const satrec = twoline2satrec(L1, L2);
|
||||
layer._setTrackedSatelliteRefreshStateForTest({
|
||||
noradId: 25544,
|
||||
name: 'ISS (ZARYA)',
|
||||
satrec,
|
||||
});
|
||||
|
||||
const FROM_MS = 1221868800000;
|
||||
const numericResult = layer.getNextSatellitePass(25544, {
|
||||
latDeg: 30.2672,
|
||||
lonDeg: -97.7431,
|
||||
fromMs: FROM_MS,
|
||||
});
|
||||
assert.equal(numericResult.status, 'ok');
|
||||
assert.ok(numericResult.pass);
|
||||
|
||||
const stringResult = layer.getNextSatellitePass('25544', {
|
||||
latDeg: 30.2672,
|
||||
lonDeg: -97.7431,
|
||||
fromMs: FROM_MS,
|
||||
});
|
||||
assert.equal(stringResult.status, 'ok');
|
||||
assert.deepEqual(stringResult, numericResult);
|
||||
|
||||
const issResult = layer.getNextIssPass({
|
||||
latDeg: 30.2672,
|
||||
lonDeg: -97.7431,
|
||||
fromMs: FROM_MS,
|
||||
});
|
||||
assert.equal(issResult.status, 'ok');
|
||||
assert.deepEqual(issResult, numericResult);
|
||||
|
||||
layer._setTrackedSatelliteRefreshStateForTest({
|
||||
noradId: 25544,
|
||||
name: 'ISS (ZARYA)',
|
||||
satrec,
|
||||
neighbours: [
|
||||
{ noradId: 10, name: 'STARLINK ONE', satrec },
|
||||
{ noradId: 11, name: 'STARLINK TWO', satrec },
|
||||
],
|
||||
});
|
||||
assert.equal(layer.resolveSatelliteForPass('25544').noradId, 25544);
|
||||
assert.equal(layer.resolveSatelliteForPass('iss (zarya)').noradId, 25544);
|
||||
assert.equal(layer.resolveSatelliteForPass('starlink').status, 'ambiguous');
|
||||
assert.equal(layer.resolveSatelliteForPass('starlink').totalMatches, 2);
|
||||
assert.equal(layer.resolveSatelliteForPass('STARLINK ONE').noradId, 10);
|
||||
assert.equal(layer.resolveSatelliteForPass('missing').status, 'not-found');
|
||||
layer._clearDenseCatalogStateForTest();
|
||||
assert.equal(layer.resolveSatelliteForPass('25544').status, 'not-found');
|
||||
|
||||
// Non-existent satellite ID in populated catalog
|
||||
assert.deepEqual(
|
||||
layer.getNextSatellitePass(99999, {
|
||||
latDeg: 30.2672,
|
||||
lonDeg: -97.7431,
|
||||
fromMs: FROM_MS,
|
||||
}),
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user