mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VVYRAqiQj9Y1V9ZfG1RcDH
181 lines
5.8 KiB
JavaScript
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'],
|
|
};
|
|
}
|