generated from ztimson/template
132 lines
5.4 KiB
JavaScript
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 };
|
|
}
|
|
}
|