// 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 }; } }