Files
eye-spy/services/sky.mjs
T
2026-10-21 22:17:35 -04:00

132 lines
5.4 KiB
JavaScript

// Sky service: target info, "what should I look at" recommendations, and overlay objects.
// Positions come from CelestialService.predict() (astronomy-engine, fully offline for Sun/Moon/planets/stars).
import * as Astronomy from 'astronomy-engine';
import { STARS } from '../lib/stars.mjs';
import { angularSeparation } from '../lib/geo.mjs';
// Bundled deep-sky showpieces (J2000 RA hours, Dec deg, visual magnitude). Swap for OpenNGC later.
const DSO = [
['m31', 'M31 Andromeda Galaxy', 'Galaxy', 0.712, 41.27, 3.4],
['m42', 'M42 Orion Nebula', 'Nebula', 5.588, -5.39, 4.0],
['m45', 'M45 Pleiades', 'Open Cluster', 3.79, 24.12, 1.6],
['m44', 'M44 Beehive', 'Open Cluster', 8.667, 19.67, 3.7],
['m13', 'M13 Hercules Cluster', 'Globular', 16.695, 36.46, 5.8],
['m8', 'M8 Lagoon Nebula', 'Nebula', 18.063, -24.38, 6.0],
['m27', 'M27 Dumbbell Nebula', 'Planetary Nebula', 19.994, 22.72, 7.4],
['m57', 'M57 Ring Nebula', 'Planetary Nebula', 18.893, 33.03, 8.8],
['m81', 'M81 Bode\'s Galaxy', 'Galaxy', 9.926, 69.07, 6.9],
['m51', 'M51 Whirlpool Galaxy', 'Galaxy', 13.498, 47.2, 8.4],
].map(([id, label, type, ra, dec, mag]) => ({ id, label, type, ra, dec, mag, spec: `radec:${ra},${dec}` }));
const PLANETS = ['mercury', 'venus', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'];
const OUTER = ['Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune'];
const r = (v, d = 1) => (v == null ? v : Math.round(v * 10 ** d) / 10 ** d);
const iso = (t) => t?.date?.toISOString?.() ?? null;
const cap = (s) => s[0].toUpperCase() + s.slice(1);
export class SkyService {
#c;
#t;
constructor({ celestial, telemetry }) {
this.#c = celestial;
this.#t = telemetry;
}
#candidates() {
return [
{ id: 'body:moon', label: 'Moon', type: 'Moon', spec: 'moon' },
...PLANETS.map((p) => ({ id: `body:${p}`, label: cap(p), type: 'Planet', spec: `body:${p}` })),
...Object.entries(STARS).map(([k, s]) => ({ id: `star:${k}`, label: s.label, type: 'Star', mag: s.mag ?? 2, spec: `star:${k}` })),
...DSO.map((d) => ({ ...d, id: `dso:${d.id}` })),
];
}
/** Detail for one target: position + (for solar-system bodies) phase, magnitude, elongation, opposition, rise/transit/set. */
info(spec) {
const date = this.#t.now();
const p = this.#c.predict(spec, { date });
const out = { ...p };
if (p.target.kind !== 'body') return out;
const name = p.target.label;
const o = new Astronomy.Observer(p.observer.lat, p.observer.lon, p.observer.altM);
const ill = Astronomy.Illumination(name, date);
out.magnitude = r(ill.mag);
out.phasePct = r(ill.phase_fraction * 100, 0);
out.distanceKm = Math.round(p.distanceAu * 149_597_870.7);
out.elongationDeg = name === 'Sun' ? 0 : r(Astronomy.AngleFromSun(name, date));
out.events = {
rise: iso(Astronomy.SearchRiseSet(name, o, +1, date, 2)),
transit: iso(Astronomy.SearchHourAngle(name, o, 0, date).time),
set: iso(Astronomy.SearchRiseSet(name, o, -1, date, 2)),
};
if (OUTER.includes(name)) {
out.oppositionPct = r((out.elongationDeg / 180) * 100, 0); // 100% = exactly at opposition
out.nextOpposition = iso(Astronomy.SearchRelativeLongitude(name, 180, date));
}
return out;
}
/** Ranked list of what's worth observing right now. */
recommend({ limit = 8, minAlt = 15 } = {}) {
const date = this.#t.now();
const moon = this.#c.predict('moon', { date });
const moonIllum = (1 - Math.cos((Astronomy.MoonPhase(date) * Math.PI) / 180)) / 2;
const rows = [];
for (const c of this.#candidates()) {
let p;
try {
p = this.#c.predict(c.spec, { date });
} catch {
continue;
}
if (p.alt < minAlt) continue;
const row = { id: c.id, spec: c.spec, label: c.label, type: c.type, az: p.az, alt: p.alt, why: [] };
let mag = c.mag;
let score = (Math.min(p.alt, 70) / 70) * 40;
if (c.type === 'Planet' || c.type === 'Moon') {
const ill = Astronomy.Illumination(c.label, date);
mag = ill.mag;
row.phasePct = r(ill.phase_fraction * 100, 0);
const el = Astronomy.AngleFromSun(c.label, date);
if (OUTER.includes(c.label)) {
row.oppositionPct = r((el / 180) * 100, 0);
if (el > 150) { score += 15; row.why.push('near opposition'); }
}
if (el < 20 && c.type === 'Planet') { score -= 30; row.why.push('close to the Sun'); }
if (c.type === 'Planet') score += 10;
}
const moonSep = angularSeparation(p.az, p.alt, moon.az, moon.alt);
if (c.type !== 'Moon' && mag > 4 && moonSep < 40) {
score -= (40 - moonSep) * 0.6 * moonIllum;
row.why.push('washed out by Moon');
}
score += Math.max(0, 10 - (mag ?? 10)) * 3;
if (p.alt > 50) row.why.push('high in sky');
row.mag = r(mag);
row.moonSepDeg = r(moonSep, 0);
row.score = r(score, 0);
rows.push(row);
}
return rows.sort((a, b) => b.score - a.score).slice(0, limit);
}
/** Everything above (roughly) the horizon, for the sky-map / live overlay. */
objects() {
const date = this.#t.now();
const out = [];
for (const c of this.#candidates()) {
try {
const p = this.#c.predict(c.spec, { date });
if (p.alt > -5) out.push({ id: c.id, spec: c.spec, label: c.label, type: c.type, mag: c.mag ?? null, az: p.az, alt: p.alt });
} catch { /* skip */ }
}
return { time: date.toISOString(), objects: out };
}
}