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:
authored and GitHub committed 2026-09-22 17:45:39 -05:00
1 parent f01b6a5d84
commit 0251c74109
180 files changed
+30645 -434

No files matched your search

+30 -69
View File
@@ -1,33 +1,26 @@
// src/data/issPass.js
/**
* Next-ISS-pass prediction from a satrec: coarse 30 s scan to find a window
* where elevation ≥ minElevDeg, refined to ~5 s at the edges, tracking peak
* elevation. ~2880 SGP4 propagations for a 24 h horizon — tens of ms, fine for
* an on-demand voice call. Pattern from skylight (MIT) shared/src/celestial.ts
* nextISSPass, extended with set-time + peak tracking.
* Next-ISS-pass prediction. Kept as a thin wrapper over satellitePass.js so the
* original findNextIssPass signature and defaults still work.
*/
import { propagate, gstime, eciToEcf, ecfToLookAngles } from 'satellite.js';
import { findNextSatellitePass, lookAnglesAt } from './satellitePass.js';
const R2D = 180 / Math.PI;
const D2R = Math.PI / 180;
/** Observer look angles at an instant, or null when propagation fails. */
export function lookAnglesAt(satrec, dateMs, latDeg, lonDeg) {
const date = new Date(dateMs);
const pv = propagate(satrec, date);
const pos = pv && pv.position;
if (!pos || typeof pos === 'boolean') return null;
const ecf = eciToEcf(pos, gstime(date));
const look = ecfToLookAngles(
{ latitude: latDeg * D2R, longitude: lonDeg * D2R, height: 0 },
ecf,
);
return {
elevDeg: look.elevation * R2D,
azDeg: (((look.azimuth * R2D) % 360) + 360) % 360,
};
}
export { lookAnglesAt };
/**
* Predict the next ISS pass over an observer location.
* @param {Object} options
* @param {Object} options.satrec SGP4 satellite record
* @param {number} options.latDeg Observer latitude in degrees [-90, 90]
* @param {number} options.lonDeg Observer longitude in degrees [-180, 180]
* @param {number} options.fromMs UTC start time in milliseconds
* @param {number} [options.minElevDeg=10] Elevation that defines rise and set, in degrees
* @param {number} [options.horizonHours=24] Maximum search window in hours
* @param {number} [options.coarseStepSec=30] Coarse search step in seconds
* @param {number} [options.fineStepSec=5] Fine transit step in seconds for peak tracking and visibility
* @param {boolean} [options.requireVisible=false] Require a naked-eye-visible pass
* @returns {{ riseMs: number, setMs: number, maxElevDeg: number, maxElevMs: number, riseAzDeg: number, visible: boolean, sunlit: boolean, observerDark: boolean } | null}
*/
export function findNextIssPass({
satrec,
latDeg,
@@ -37,49 +30,17 @@ export function findNextIssPass({
horizonHours = 24,
coarseStepSec = 30,
fineStepSec = 5,
requireVisible = false,
}) {
const elev = (t) => lookAnglesAt(satrec, t, latDeg, lonDeg)?.elevDeg ?? -90;
const horizonMs = fromMs + horizonHours * 3600_000;
const coarse = coarseStepSec * 1000;
const fine = fineStepSec * 1000;
// Coarse scan for the first sample above threshold. If we START inside a
// pass, that's still "the next pass" for a voice answer — accept it.
let hit = null;
for (let t = fromMs; t <= horizonMs; t += coarse) {
if (elev(t) >= minElevDeg) {
hit = t;
break;
}
}
if (hit == null) return null;
// Refine rise: walk back in fine steps to the first sample ≥ threshold.
let riseMs = hit;
while (riseMs - fine > fromMs && elev(riseMs - fine) >= minElevDeg)
riseMs -= fine;
// Walk forward through the pass tracking the peak until we drop below.
let maxElevDeg = -90;
let maxElevMs = riseMs;
let t = riseMs;
while (t <= horizonMs) {
const e = elev(t);
if (e < minElevDeg && t > riseMs) break;
if (e > maxElevDeg) {
maxElevDeg = e;
maxElevMs = t;
}
t += fine;
}
const setMs = t;
const rise = lookAnglesAt(satrec, riseMs, latDeg, lonDeg);
return {
riseMs,
setMs,
maxElevDeg,
maxElevMs,
riseAzDeg: rise ? rise.azDeg : 0,
};
return findNextSatellitePass({
satrec,
latDeg,
lonDeg,
fromMs,
minElevDeg,
horizonHours,
coarseStepSec,
fineStepSec,
requireVisible,
});
}