Files
gods-eye-view/mobile/globe/sky.js
T

181 lines
5.8 KiB
JavaScript

/**
* "Your sky now" (DESIGN.md §12.3): what is above a person this minute.
* Aircraft within ~50 km from the live ADS-B feeds (fetching a regional
* snapshot around the person when the current one does not cover them),
* satellites above the horizon from CelesTrak elements (SGP4), and the ISS
* with its next pass.
*/
import { twoline2satrec } from 'satellite.js';
import { findNextIssPass, lookAnglesAt } from '../../src/data/issPass.js';
import { haversineKm, subsolarPoint } from './geo.js';
import { bearingDeg } from './gazetteer.js';
const R = 6_371_000;
const D2R = Math.PI / 180;
const ISS_NORAD = '25544';
function elevationDeg(distM, altM) {
// Flat-earth elevation corrected for the curvature drop over distM.
const drop = (distM * distM) / (2 * R);
return Math.atan2(altM - drop, distM) / D2R;
}
function sunElevation(lat, lon, ms) {
const s = subsolarPoint(ms);
const cosZ =
Math.sin(lat * D2R) * Math.sin(s.lat * D2R) +
Math.cos(lat * D2R) * Math.cos(s.lat * D2R) * Math.cos((lon - s.lon) * D2R);
return 90 - Math.acos(Math.max(-1, Math.min(1, cosZ))) / D2R;
}
const satCache = { stamp: '', sats: [] };
function satrecs(feeds) {
const groups = ['stations', 'visual'];
const stamp = groups.map((g) => feeds[g].updatedAt).join(':');
if (stamp === satCache.stamp) return satCache.sats;
const seen = new Set();
const sats = [];
for (const g of groups)
for (const e of feeds[g].data?.entries || []) {
const id = e.line1.slice(2, 7).trim();
if (seen.has(id)) continue;
seen.add(id);
try {
sats.push({
id,
name: e.name.trim(),
satrec: twoline2satrec(e.line1, e.line2),
});
} catch {
/* malformed */
}
}
satCache.stamp = stamp;
satCache.sats = sats;
return sats;
}
export async function skyNow(feeds, { lon, lat }, { km = 50 } = {}) {
if (!Number.isFinite(lon) || !Number.isFinite(lat))
throw new TypeError('skyNow needs lon and lat');
const now = Date.now();
// Aircraft: whatever the feeds hold near the person, plus a regional
// snapshot around them when nothing current covers the spot.
const lists = [];
const flights = feeds.flights.data;
if (flights?.records) lists.push(flights.records);
if (feeds.military.data?.records) lists.push(feeds.military.data.records);
for (const e of feeds.regionalFlights.entries?.() || [])
if (e.data?.records) lists.push(e.data.records);
const covered =
flights &&
(!flights.regional ||
(flights.anchor &&
haversineKm(lat, lon, flights.anchor.lat, flights.anchor.lon) < 300));
if (!covered) {
const key = `sky:${lat.toFixed(1)}:${lon.toFixed(1)}`;
const reg = await Promise.race([
feeds.regionalFlights.get(key, { lat, lon }).catch(() => null),
new Promise((r) => setTimeout(() => r(null), 9000)),
]);
if (reg?.records) lists.push(reg.records);
}
const seen = new Set();
const aircraft = [];
for (const list of lists)
for (const r of list) {
if (seen.has(r.id) || r.onGround) continue;
if (Math.abs(r.lat - lat) > km / 100 + 0.2) continue;
const d = haversineKm(lat, lon, r.lat, r.lon);
if (d > km) continue;
seen.add(r.id);
aircraft.push({
id: `${r.military ? 'military' : 'flights'}:${r.id}`,
title: r.callsign || r.id.toUpperCase(),
kind: r.military
? 'military'
: r.helicopter
? 'helicopter'
: 'aircraft',
lon: r.lon,
lat: r.lat,
altM: Math.round(r.altM ?? 0),
distanceKm: Math.round(d * 10) / 10,
elevationDeg: Math.round(elevationDeg(d * 1000, r.altM ?? 0) * 10) / 10,
azimuthDeg: Math.round(bearingDeg(lat, lon, r.lat, r.lon)),
headingDeg: r.heading ?? null,
speedMps: r.speed ?? null,
squawk: r.squawk || null,
});
}
aircraft.sort((a, b) => b.elevationDeg - a.elevationDeg);
// Satellites above the horizon.
// Cached elements answer at once; a cold catalog gets 4 s.
await Promise.race([
Promise.allSettled([feeds.stations.get(), feeds.visual.get()]),
new Promise((r) => setTimeout(r, 4000)),
]);
const satellites = [];
let iss = null;
for (const s of satrecs(feeds)) {
const look = lookAnglesAt(s.satrec, now, lat, lon);
if (!look) continue;
if (s.id === ISS_NORAD) iss = { s, look };
if (look.elevDeg <= 0) continue;
const p = look.satECI;
satellites.push({
id: `satellites:${s.id}`,
title: s.name,
norad: s.id,
altKm: p ? Math.round(Math.hypot(p.x, p.y, p.z) - 6371) : null,
elevationDeg: Math.round(look.elevDeg * 10) / 10,
azimuthDeg: Math.round(look.azDeg),
});
}
satellites.sort((a, b) => b.elevationDeg - a.elevationDeg);
const sunEl = sunElevation(lat, lon, now);
let issOut = {
visibleNow: false,
elevationDeg: null,
azimuthDeg: null,
nextPass: null,
};
if (iss) {
const pass = findNextIssPass({
satrec: iss.s.satrec,
latDeg: lat,
lonDeg: lon,
fromMs: now,
minElevDeg: 10,
horizonHours: 36,
});
issOut = {
visibleNow: iss.look.elevDeg > 10 && sunEl < -6,
aboveHorizon: iss.look.elevDeg > 0,
elevationDeg: Math.round(iss.look.elevDeg * 10) / 10,
azimuthDeg: Math.round(iss.look.azDeg),
nextPass: pass
? {
start: pass.riseMs,
max: pass.maxElevMs,
end: pass.setMs,
maxElevationDeg: Math.round(pass.maxElevDeg),
riseAzimuthDeg: Math.round(pass.riseAzDeg ?? 0),
visible: Boolean(pass.visible),
}
: null,
};
}
return {
at: now,
observer: { lon, lat },
sunElevationDeg: Math.round(sunEl * 10) / 10,
aircraft: aircraft.slice(0, 60),
satellites: satellites.slice(0, 40),
iss: issOut,
sources: ['OpenSky Network / adsb.lol (ODbL)', 'CelesTrak GP data, SGP4'],
};
}