This commit is contained in:
2026-10-21 22:17:35 -04:00
parent b74d278c8e
commit 92ef420d89
8 changed files with 688 additions and 28 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"shutterUs": 0,
"gain": 0,
"ev": -0.04,
"exposureMode": "long",
"brightness": 0,
"contrast": 1,
"saturation": 1,
"sharpness": 1,
"awb": "fluorescent",
"awbGains": null,
"denoise": "off",
"autofocusMode": "manual",
"autofocusRange": "macro",
"autofocusSpeed": "normal",
"lensPosition": 4.8,
"hflip": false,
"vflip": false,
"rotation": 0,
"quality": 93,
"roi": null
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

@@ -0,0 +1,86 @@
{
"type": "photo",
"name": "photo_20261022T003029Z.jpg",
"url": "/media/photos/photo_20261022T003029Z.jpg",
"raw": null,
"bytes": 2766941,
"durationMs": 4318,
"takenAt": "2026-10-22T00:30:33.571Z",
"settings": {
"shutterUs": 0,
"gain": 0,
"ev": 0,
"exposureMode": "normal",
"brightness": 0,
"contrast": 1,
"saturation": 1,
"sharpness": 1,
"awb": "auto",
"awbGains": null,
"denoise": "auto",
"autofocusMode": "manual",
"autofocusRange": "normal",
"autofocusSpeed": "normal",
"lensPosition": 0,
"hflip": false,
"vflip": false,
"rotation": 0,
"quality": 93,
"roi": null,
"iso": 0
},
"meta": {
"telemetry": {
"time": {
"iso": "2026-10-22T00:30:29.252Z",
"epochMs": 1792629029252
},
"sensor": {
"state": "backoff",
"sampleAgeMs": null,
"restarts": 6,
"lastError": "exited (code=1, signal=null)"
},
"orientation": null,
"battery": {
"state": "ok",
"available": true,
"source": "pisugar-server",
"socketPath": "/tmp/pisugar-server.sock",
"voltageV": 3.874,
"percent": 85,
"percentEstimate": 85,
"percentIsEstimate": false,
"powerPlugged": true,
"chargingAllowed": true,
"charging": true,
"updatedAt": "2026-10-22T00:30:25.646Z",
"error": null
},
"gps": {
"state": "no_fix",
"position": null,
"satellites": {
"used": 0,
"seen": 26
},
"dop": {
"hdop": null,
"vdop": null,
"pdop": null
},
"speedMps": null,
"clockSkewMs": -609,
"errorM": {
"x": null,
"y": null,
"v": null
}
}
},
"pointing": {
"available": false,
"error": "no_orientation"
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

@@ -0,0 +1,86 @@
{
"type": "photo",
"name": "photo_20261022T005834Z.jpg",
"url": "/media/photos/photo_20261022T005834Z.jpg",
"raw": null,
"bytes": 4131974,
"durationMs": 3962,
"takenAt": "2026-10-22T00:58:38.129Z",
"settings": {
"shutterUs": 0,
"gain": 5,
"ev": 0,
"exposureMode": "long",
"brightness": 0,
"contrast": 1,
"saturation": 1,
"sharpness": 1,
"awb": "indoor",
"awbGains": null,
"denoise": "off",
"autofocusMode": "continuous",
"autofocusRange": "macro",
"autofocusSpeed": "normal",
"lensPosition": 0.4,
"hflip": false,
"vflip": false,
"rotation": 0,
"quality": 93,
"roi": null,
"iso": 500
},
"meta": {
"telemetry": {
"time": {
"iso": "2026-10-22T00:58:34.160Z",
"epochMs": 1792630714160
},
"sensor": {
"state": "backoff",
"sampleAgeMs": null,
"restarts": 11,
"lastError": "exited (code=1, signal=null)"
},
"orientation": null,
"battery": {
"state": "ok",
"available": true,
"source": "pisugar-server",
"socketPath": "/tmp/pisugar-server.sock",
"voltageV": 3.893,
"percent": 87,
"percentEstimate": 87,
"percentIsEstimate": false,
"powerPlugged": true,
"chargingAllowed": true,
"charging": true,
"updatedAt": "2026-10-22T00:58:31.250Z",
"error": null
},
"gps": {
"state": "no_fix",
"position": null,
"satellites": {
"used": 0,
"seen": 20
},
"dop": {
"hdop": null,
"vdop": null,
"pdop": null
},
"speedMps": null,
"clockSkewMs": -80,
"errorM": {
"x": null,
"y": null,
"v": null
}
}
},
"pointing": {
"available": false,
"error": "no_orientation"
}
}
}
+206
View File
@@ -0,0 +1,206 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MRS Telescope</title>
<style>
:root{--bg:#06080e;--card:#0c111d;--line:#1b2440;--tx:#e6ebf7;--mut:#7f8aa8;--ac:#2f7bff;--ok:#3ddc84;--bad:#ff5c6c;--warn:#ffc247}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--tx);font:13px system-ui,sans-serif;height:100vh;display:grid;grid-template-columns:auto 1fr 1fr;gap:6px;padding:6px;overflow:hidden}
#view{position:relative;height:calc(100vh - 12px);aspect-ratio:1;background:#000;border:1px solid var(--line);border-radius:10px;overflow:hidden}
#view img,#ov{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
.hud{position:absolute;background:#060a14cc;border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-variant-numeric:tabular-nums;backdrop-filter:blur(4px)}
#warn{position:absolute;left:50%;top:45%;transform:translate(-50%,-50%);text-align:center;background:#1a1208ee;border:1px solid var(--warn);color:var(--warn);border-radius:10px;padding:14px 18px;max-width:80%;display:none}
#pip{position:absolute;top:10px;right:10px;width:210px;height:210px;border:1px solid var(--line);border-radius:50%;background:#060a14dd}
#ovc{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);display:flex;gap:4px;padding:4px 6px}
.col{display:flex;flex-direction:column;gap:6px;overflow:auto;scrollbar-width:none;min-width:0}.col::-webkit-scrollbar{display:none}
section{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 12px;flex:none}
h3{margin:0 0 8px;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);display:flex;justify-content:space-between}
h3 small{text-transform:none;letter-spacing:0}
input,button{font:inherit;color:var(--tx)}
input[type=text],input[type=number]{background:#111829;border:1px solid var(--line);border-radius:6px;padding:5px 7px;width:100%}
button{background:#121a2e;border:1px solid var(--line);border-radius:6px;padding:5px 9px;cursor:pointer}
button:hover:not(:disabled){border-color:var(--ac)}button:disabled{opacity:.35;cursor:not-allowed}
button.p{background:var(--ac);border-color:var(--ac)}
.chip{border-radius:99px;padding:3px 10px;font-size:12px}.chip.on,.segs button.on{background:var(--ac);border-color:var(--ac)}
.segs{display:flex;gap:3px}.segs button{flex:1;padding:4px 2px;font-size:12px}
.ctl{margin:8px 0}.lh{display:flex;justify-content:space-between;color:var(--mut);margin-bottom:4px}.lh b{color:var(--tx);font-weight:600}
input[type=range]{width:100%;accent-color:var(--ac);height:18px;margin:0}
#cam.af input[data-k=lensPosition]{opacity:.35;pointer-events:none}
details summary{cursor:pointer;color:var(--mut);margin:8px 0}.flips{display:flex;gap:4px;flex-wrap:wrap}
.g2{display:grid;grid-template-columns:1fr 1fr;gap:6px}.tile{background:#0f1626;border:1px solid var(--line);border-radius:8px;padding:8px}
.big{font-size:20px;font-weight:600}.mut{color:var(--mut)}.ok{color:var(--ok)}.bad{color:var(--bad)}.dots i{display:inline-block;width:6px;height:6px;border-radius:50%;background:#26304d;margin:1px}.dots i.u{background:var(--ok)}
.dpad{display:grid;grid-template-columns:repeat(3,38px);gap:4px}.dpad button{height:38px;padding:0}
.shut{width:62px;height:62px;border-radius:50%;border:4px solid #fff;background:var(--bad);margin:4px auto 0;display:block}.shut.rec{border-radius:14px}
table{width:100%;border-collapse:collapse}td,th{padding:4px;text-align:left;font-weight:400}tr.rec{cursor:pointer}tr.rec:hover,tr.sel{background:#16203a}
#toast{position:fixed;bottom:14px;left:14px;background:#2a1018;border:1px solid var(--bad);padding:8px 12px;border-radius:6px;display:none;z-index:9}
@media(max-width:1000px){body{grid-template-columns:1fr;height:auto;overflow:auto}#view{height:auto;width:100%}}
</style></head><body>
<div id="view">
<img id="stream" alt="live view"><canvas id="ov"></canvas>
<div id="warn"></div>
<div class="hud" style="top:10px;left:10px"><b>Live View</b><div id="hudPoint" class="mut">no pointing</div><div id="hudDelta"></div></div>
<canvas id="pip" width="420" height="420"></canvas>
<div class="hud" style="bottom:52px;left:10px;width:230px"><div class="lh"><span>Field of view</span><b id="fovV"></b></div><input type="range" id="fov" min="0" max="100"></div>
<div class="hud" style="bottom:52px;right:10px" id="hudCam"></div>
<div class="hud" id="ovc"></div>
</div>
<div class="col">
<section><h3>Target <small id="tName"></small></h3>
<div class="g2"><input id="tIn" type="text" placeholder="moon, vega, iss, sat:25544…"><button class="p" id="tSet">Set target</button></div>
<div id="tInfo" style="margin-top:8px"></div>
<div class="g2" style="margin-top:8px"><button id="zero">Zero on target</button><button id="unzero">Clear alignment</button></div><div id="align" class="mut" style="margin-top:6px"></div></section>
<section><h3>Mount &amp; focuser <small>hardware not connected</small></h3>
<div style="display:flex;gap:18px;align-items:center;justify-content:center">
<div class="dpad"><span></span><button disabled>▲</button><span></span><button disabled>◀</button><button disabled>■</button><button disabled>▶</button><span></span><button disabled>▼</button><span></span></div>
<div style="display:flex;flex-direction:column;gap:4px;align-items:center"><span class="mut">Focus</span><button disabled style="width:60px">Out ▲</button><b id="fpos" class="mut">–</b><button disabled style="width:60px">In ▼</button></div></div>
<div class="ctl"><div class="lh">Slew speed</div><div class="segs"><button disabled>1</button><button disabled>2</button><button disabled class="on">3</button><button disabled>4</button><button disabled>5</button></div></div>
<div class="ctl"><div class="lh">Focus step</div><div class="segs"><button disabled>Fine</button><button disabled class="on">Medium</button><button disabled>Coarse</button></div></div></section>
<section><h3>Orientation <small id="oAl"></small></h3>
<div style="display:flex;gap:10px;align-items:center"><canvas id="att" width="200" height="200" style="width:170px;height:170px"></canvas><div id="oNums" style="flex:1"></div></div></section>
<section><h3>Telemetry</h3><div class="g2" id="tele"></div></section>
</div>
<div class="col">
<section><h3>Capture <small id="camMode"></small></h3>
<div class="segs" id="capTabs"><button data-m="single" class="on">Photo</button><button data-m="timelapse">Timelapse</button><button data-m="video">Video</button></div>
<div id="capFields"></div><button class="shut" id="capGo" title="Capture"></button><div id="capJob" class="mut" style="margin-top:6px;text-align:center"></div></section>
<section><h3>Camera <button id="camReset" style="padding:1px 8px">Reset</button></h3><div id="cam"></div></section>
<section><h3>Recommended now</h3><table id="recs"></table></section>
</div>
<div id="toast"></div>
<script>
const $=(i)=>document.getElementById(i),rad=Math.PI/180,wrap180=(a)=>((a+540)%360)-180;
const ST=(k,d)=>{try{return JSON.parse(localStorage.getItem(k))??d}catch{return d}},SV=(k,v)=>{try{localStorage.setItem(k,JSON.stringify(v))}catch{}};
const S={pt:null,delta:null,objs:[],target:ST('target',''),cap:'single',cam:null,st:null,fovV:ST('fovV',40)},O=ST('ov',{labels:1,stars:1,dso:1,cross:1,err:1,map:1});
const j=async(u,o)=>{const r=await fetch(u,o&&{...o,headers:{'Content-Type':'application/json'},body:o.body&&JSON.stringify(o.body)});const d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.message||d.error||r.status);return d};
const toast=(m)=>{const t=$('toast');t.textContent=m;t.style.display='block';clearTimeout(toast.t);toast.t=setTimeout(()=>t.style.display='none',4000)};
const act=(f)=>async(...a)=>{try{await f(...a)}catch(e){toast(e.message)}};
const tm=(s)=>s?new Date(s).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'}):'—';
$('stream').src='/stream';
// ---------- camera controls (camera-app style) ----------
const SH=[0,250,500,1e3,2e3,4e3,8e3,16e3,33333,66666,125e3,25e4,5e5,1e6,2e6,5e6,1e7],IS=[0,100,200,400,800,1600,3200,6400],f2=(x)=>(+x).toFixed(2);
const fs=(u)=>u?(u>=1e6?u/1e6+' s':'1/'+Math.round(1e6/u)):'Auto';
const MAIN=[['shutterUs','Shutter','steps',SH,fs],['iso','ISO','steps',IS,(x)=>x||'Auto'],['ev','Exposure comp','range',-4,4,.33,(x)=>(x>0?'+':'')+(+x).toFixed(1)+' EV'],
['autofocusMode','Focus','seg',['manual','auto','continuous']],['lensPosition','Lens position','range',0,12,.1,(x)=>(+x).toFixed(1)+' D'],
['awb','White balance','seg',['auto','daylight','cloudy','tungsten','fluorescent']]];
const MORE=[['brightness','Brightness','range',-1,1,.05,f2],['contrast','Contrast','range',0,4,.05,f2],['saturation','Saturation','range',0,4,.05,f2],['sharpness','Sharpness','range',0,8,.25,f2],['quality','JPEG quality','range',1,100,1,(x)=>x],
['denoise','Denoise','seg',['auto','off','cdn_fast','cdn_hq']],['exposureMode','Exposure mode','seg',['normal','sport','long']],['rotation','Rotation','seg',[0,180]]];
const M={};
const ctl=(r)=>{const[k,l,t]=r;
if(t==='seg')return`<div class="ctl"><div class="lh">${l}</div><div class="segs" data-k="${k}">${r[3].map((o)=>`<button data-v="${o}">${o}</button>`).join('')}</div></div>`;
M[k]=t==='steps'?{vals:r[3],f:r[4],min:0,max:r[3].length-1,step:1}:{min:r[3],max:r[4],step:r[5],f:r[6]};const m=M[k];
return`<div class="ctl"><div class="lh"><span>${l}</span><b id="v_${k}"></b></div><input type="range" data-k="${k}" min="${m.min}" max="${m.max}" step="${m.step}"></div>`};
$('cam').innerHTML=MAIN.map(ctl).join('')+`<details><summary>Image &amp; advanced</summary>${MORE.map(ctl).join('')}<div class="flips"><button class="chip" data-k="hflip">Mirror H</button><button class="chip" data-k="vflip">Mirror V</button></div></details>`;
function paint(){const s=S.cam;document.querySelectorAll('#cam [data-k]').forEach((el)=>{const k=el.dataset.k,v=s[k];
if(el.type==='range'){if(document.activeElement===el)return;const m=M[k];el.value=m.vals?m.vals.reduce((b,x,i)=>Math.abs(x-v)<Math.abs(m.vals[b]-v)?i:b,0):(v??0);$('v_'+k).textContent=m.f(m.vals?m.vals[el.value]:+el.value)}
else if(el.classList.contains('segs'))el.querySelectorAll('button').forEach((b)=>b.classList.toggle('on',b.dataset.v==String(v)));
else el.classList.toggle('on',!!v)});$('cam').classList.toggle('af',s.autofocusMode!=='manual')}
const tmr={};function send(k,v){S.cam[k]=v;clearTimeout(tmr[k]);tmr[k]=setTimeout(act(async()=>{S.cam=(await j('/api/camera/settings',{method:'PATCH',body:{[k]:v}})).settings;paint()}),250)}
$('cam').addEventListener('input',(e)=>{const el=e.target,k=el.dataset.k;if(el.type!=='range'||!k)return;const m=M[k],v=m.vals?m.vals[+el.value]:+el.value;$('v_'+k).textContent=m.f(v);send(k,v)});
$('cam').addEventListener('click',(e)=>{const b=e.target.closest('button');if(!b||!S.cam)return;const k=(b.closest('[data-k]')||b).dataset.k;
if(b.dataset.v!==undefined)send(k,k==='rotation'?Number(b.dataset.v):b.dataset.v);else send(k,!S.cam[k]);paint()});
$('camReset').onclick=act(async()=>{S.cam=(await j('/api/camera/settings/reset',{method:'POST',body:{}})).settings;paint()});
// ---------- capture ----------
const CAPF={single:`<div class="ctl lh"><span>Also save RAW (.dng)</span><input type="checkbox" id="raw"></div>`,
timelapse:`<div class="g2"><div class="ctl"><div class="lh">Interval (s)</div><input type="number" id="tlI" value="10"></div><div class="ctl"><div class="lh">Frames</div><input type="number" id="tlN" value="180"></div></div>`,
video:`<div class="g2"><div class="ctl"><div class="lh">Length (s, 0 = manual stop)</div><input type="number" id="vD" value="0"></div><div class="ctl"><div class="lh">FPS</div><input type="number" id="vF" value="30"></div></div>`};
const capUI=()=>{$('capFields').innerHTML=CAPF[S.cap];document.querySelectorAll('#capTabs button').forEach((b)=>b.classList.toggle('on',b.dataset.m===S.cap))};
$('capTabs').onclick=(e)=>{if(e.target.dataset.m){S.cap=e.target.dataset.m;capUI()}};capUI();
const nv=(i)=>Number($(i).value);
$('capGo').onclick=act(async()=>{const job=S.st?.camera?.job;
if(job?.type==='video')return j('/api/camera/video/stop',{method:'POST',body:{}});
if(job?.type==='timelapse')return j('/api/camera/timelapse/stop',{method:'POST',body:{}});
if(S.cap==='single'){const r=await j('/api/camera/photo',{method:'POST',body:{raw:$('raw').checked}});toast('Saved '+r.name)}
else if(S.cap==='timelapse')await j('/api/camera/timelapse/start',{method:'POST',body:{intervalSec:nv('tlI'),count:nv('tlN')}});
else await j('/api/camera/video/start',{method:'POST',body:{durationSec:nv('vD'),fps:nv('vF')}})});
// ---------- target ----------
$('tIn').value=S.target;
$('tSet').onclick=()=>setTarget($('tIn').value.trim());
function setTarget(t){S.target=t;SV('target',t);$('tIn').value=t;S.delta=null;refreshInfo();loadRecs();tick()}
$('zero').onclick=act(async()=>{const r=await j('/api/celestial/zero',{method:'POST',body:{target:S.target}});toast(`Aligned. Correction ${r.correction?.separation??'—'}°`)});
$('unzero').onclick=act(()=>j('/api/celestial/zero',{method:'DELETE'}));
async function refreshInfo(){if(!S.target){$('tInfo').innerHTML='<div class="mut">Pick a recommendation or type a target.</div>';return}
try{const i=await j('/api/sky/info?target='+encodeURIComponent(S.target)),e=i.events;$('tName').textContent=i.target.label;
const kv=(a,b)=>`<div class="mut">${a}</div><div>${b}</div>`;
$('tInfo').innerHTML=`<div class="g2">${kv('Alt / Az',i.alt+'° / '+i.az+'°')}${i.magnitude!=null?kv('Magnitude',i.magnitude)+kv('Distance',(i.distanceKm/1e6).toFixed(1)+'M km ('+i.distanceAu+' AU)')+kv('Illuminated',i.phasePct+'%')+kv('Elongation',i.elongationDeg+'°'):''}
${i.oppositionPct!=null?kv('Opposition',i.oppositionPct+'% · next '+new Date(i.nextOpposition).toLocaleDateString()):''}${e?kv('Rise / Transit / Set',tm(e.rise)+' / '+tm(e.transit)+' / '+tm(e.set)):''}${i.rangeKm?kv('Range',i.rangeKm+' km'):''}</div>${i.aboveHorizon?'':'<div class="bad">Below the horizon</div>'}`}
catch(e){$('tInfo').innerHTML=`<div class="bad">${e.message}</div>`}}
// ---------- status polling / render ----------
const ring=(p,c)=>`<svg viewBox="0 0 36 36" width="54"><circle cx="18" cy="18" r="15.9" fill="none" stroke="#1b2440" stroke-width="3.5"/><circle cx="18" cy="18" r="15.9" fill="none" stroke="${c}" stroke-width="3.5" pathLength="100" stroke-dasharray="${p} 100" stroke-linecap="round" transform="rotate(-90 18 18)"/><text x="18" y="21" text-anchor="middle" font-size="9" fill="#e6ebf7">${p}%</text></svg>`;
async function tick(){
try{S.st=await j('/api/status');const p=S.st.pointing;S.pt=p&&p.az!=null?p:null;render(S.st)}catch{$('hudPoint').textContent='server unreachable'}
if(S.target&&S.pt)try{S.delta=await j('/api/celestial/delta?target='+encodeURIComponent(S.target))}catch{S.delta=null}else S.delta=null;
draw();pip();att()}
function render(st){
const t=st.telemetry,c=st.camera,b=t.battery,g=t.gps,s=t.sensor,a=st.celestial.alignment,e=st.pointing?.error;
$('warn').style.display=S.pt?'none':'block';
$('warn').innerHTML=e==='no_position'?'<b>No observer position</b><br>Overlays and targets need a location. Wait for a GPS fix, or start the server with FALLBACK_LAT and FALLBACK_LON.':e==='no_orientation'?`<b>No IMU data</b><br>Sensor state: ${s.state}`:`<b>No pointing</b><br>${e||''}`;
$('hudPoint').innerHTML=S.pt?`Az ${S.pt.az}° &nbsp; Alt ${S.pt.alt}°${S.pt.aligned?'':' <span class="bad">(unaligned)</span>'}`:'<span class="bad">no pointing</span>';
const d=S.delta;$('hudDelta').innerHTML=d?`Δaz ${d.deltaAz}° ${d.hint.azimuth} · Δalt ${d.deltaAlt}° ${d.hint.altitude}<br>${d.onTarget?'<b class="ok">ON TARGET</b>':'separation '+d.separation+'°'}`:'';
const cs=c.settings;$('hudCam').textContent=`${fs(cs.shutterUs)} · ISO ${cs.iso||'Auto'} · ${c.stream.running?c.stream.width+'×'+c.stream.height+' @ '+c.stream.fps.toFixed(0)+' fps':'stream idle'}`;
if(!S.cam){S.cam=cs;paint()}
$('camMode').textContent=c.mode+(c.streamClients?` · ${c.streamClients} viewing`:'');
const jb=c.job;$('capJob').textContent=jb?(jb.type==='timelapse'?`Timelapse ${jb.frames}/${jb.total??'∞'}`:`Recording ${jb.elapsedSec}s`):(c.lastCapture?`Last: ${c.lastCapture.type}`:'');
$('capGo').classList.toggle('rec',!!jb);$('fpos').textContent=c.focus.position;
$('align').textContent=a.aligned?`Aligned on ${a.target?.label} at ${new Date(a.createdAt).toLocaleTimeString()}`:'Not aligned: centre a known object and press Zero.';
$('oAl').innerHTML=a.aligned?'<span class="ok">aligned</span>':'<span class="bad">unaligned</span>';
const o=t.orientation,n=(k,v,u='°')=>`<div class="lh"><span>${k}</span><b>${v}${u}</b></div>`;
$('oNums').innerHTML=(o?n('Yaw',o.yaw.toFixed(1))+n('Pitch',o.pitch.toFixed(1))+n('Roll',o.roll.toFixed(1)):'<div class="bad">no IMU data</div>')+(S.pt?n('Pointing az',S.pt.az)+n('Pointing alt',S.pt.alt)+n('Declination',S.pt.declination.deg):'');
const pct=b.available?b.percent:0,bc=pct<20?'#ff5c6c':pct<50?'#ffc247':'#3ddc84',fix=g.state.startsWith('fix');
const dots=g.satellites?Array.from({length:g.satellites.seen},(_,i)=>`<i class="${i<g.satellites.used?'u':''}"></i>`).join(''):'';
$('tele').innerHTML=`<div class="tile"><div class="mut">Battery</div><div style="display:flex;align-items:center;gap:8px">${ring(pct,bc)}<div>${b.available?b.voltageV+' V<br>'+(b.charging?'<span class="ok">charging ⚡</span>':'discharging'):'<span class="bad">unavailable</span>'}</div></div></div>
<div class="tile"><div class="mut">GPS</div><div class="big ${fix?'ok':'bad'}">${g.state.replace('_',' ')}</div><div class="dots">${dots}</div><div class="mut">${g.satellites?g.satellites.used+'/'+g.satellites.seen+' sats':''} ${g.dop?.hdop!=null?'· HDOP '+g.dop.hdop:''}</div></div>
<div class="tile"><div class="mut">Position</div>${g.position?`<div>${g.position.lat.toFixed(4)}, ${g.position.lon.toFixed(4)}</div><div class="mut">${g.position.altM.toFixed(0)} m${g.position.live?'':' · stale'}</div>`:'<div class="bad">none yet</div>'}</div>
<div class="tile"><div class="mut">IMU</div><div class="big ${s.state==='running'?'ok':'bad'}">${s.state}</div><div class="mut">${s.sampleAgeMs!=null?s.sampleAgeMs+' ms old':''} · ${s.restarts} restarts</div></div>
<div class="tile"><div class="mut">Clock</div><div class="big">${new Date(t.time.epochMs).toLocaleTimeString()}</div><div class="mut">${g.clockSkewMs!=null?'GPS skew '+g.clockSkewMs+' ms':'no GPS time'}</div></div>
<div class="tile"><div class="mut">Satellite TLEs</div><div class="big">${st.celestial.tle.satellites}</div><div class="${st.celestial.tle.lastError?'bad':'mut'}">${st.celestial.tle.lastError?'refresh failing':'cached'}</div></div>`}
// ---------- view overlays ----------
const fovDeg=()=>0.2*Math.pow(150,S.fovV/100);
$('fov').value=S.fovV;$('fov').oninput=()=>{S.fovV=+$('fov').value;SV('fovV',S.fovV);$('fovV').textContent=fovDeg().toFixed(1)+'°';draw()};$('fovV').textContent=fovDeg().toFixed(1)+'°';
const OVL=[['labels','Labels'],['stars','Stars'],['dso','Deep sky'],['cross','Crosshair'],['err','Error line'],['map','Sky map']];
$('ovc').innerHTML=OVL.map(([k,l])=>`<button class="chip ${O[k]?'on':''}" data-k="${k}">${l}</button>`).join('');
$('ovc').onclick=(e)=>{const k=e.target.dataset.k;if(!k)return;O[k]=O[k]?0:1;SV('ov',O);e.target.classList.toggle('on',!!O[k]);draw();pip()};
function draw(){
const c=$('ov'),g=c.getContext('2d'),W=c.width=c.clientWidth,H=c.height=c.clientHeight;g.clearRect(0,0,W,H);
if(!S.pt)return;const ppd=W/fovDeg(),cx=W/2,cy=H/2,P=S.pt,proj=(o)=>[cx+wrap180(o.az-P.az)*Math.cos(P.alt*rad)*ppd,cy-(o.alt-P.alt)*ppd];
g.font='12px system-ui';g.textBaseline='middle';
if(O.labels)for(const o of S.objs){const dso=/Galaxy|Nebula|Cluster|Globular/.test(o.type);if(o.type==='Star'&&!O.stars||dso&&!O.dso)continue;
const[x,y]=proj(o);if(x<-20||y<-20||x>W+20||y>H+20)continue;g.strokeStyle=g.fillStyle='#8fb4ff';g.beginPath();g.arc(x,y,9,0,7);g.stroke();g.fillText(o.label,x+14,y)}
if(O.cross){g.strokeStyle='#ff5c6c99';g.beginPath();g.moveTo(cx-22,cy);g.lineTo(cx+22,cy);g.moveTo(cx,cy-22);g.lineTo(cx,cy+22);g.stroke()}
const d=S.delta;if(d&&O.err){let[x,y]=proj(d.target);const off=x<0||y<0||x>W||y>H;g.strokeStyle=g.fillStyle=d.onTarget?'#3ddc84':'#ffc247';g.setLineDash([6,5]);g.beginPath();g.moveTo(cx,cy);
if(off){const k=.45*W/Math.hypot(x-cx,y-cy);x=cx+(x-cx)*k;y=cy+(y-cy)*k}g.lineTo(x,y);g.stroke();g.setLineDash([]);g.beginPath();g.arc(x,y,12,0,7);g.stroke();g.fillText(`${d.target.target.label} ${d.separation}°${off?' (off-screen)':''}`,x+16,y)}}
function pip(){
const c=$('pip');c.style.display=O.map?'block':'none';const g=c.getContext('2d'),R=185,cx=210,cy=210;g.clearRect(0,0,420,420);
g.strokeStyle='#1b2440';g.fillStyle='#7f8aa8';g.font='20px system-ui';g.textAlign='center';[R,R*2/3,R/3].forEach((r)=>{g.beginPath();g.arc(cx,cy,r,0,7);g.stroke()});
[['N',0],['E',90],['S',180],['W',270]].forEach(([l,a])=>g.fillText(l,cx-(R+14)*Math.sin(a*rad),cy-(R+14)*Math.cos(a*rad)+6));
const xy=(az,alt)=>{const r=(90-Math.max(alt,0))/90*R;return[cx-r*Math.sin(az*rad),cy-r*Math.cos(az*rad)]};g.font='16px system-ui';
for(const o of S.objs){if(o.alt<0)continue;const[x,y]=xy(o.az,o.alt),t=o.spec===S.target;g.fillStyle=t?'#ffc247':'#cfd8f5';g.beginPath();g.arc(x,y,t?7:/Planet|Moon/.test(o.type)?5:3,0,7);g.fill();if(t||/Planet|Moon/.test(o.type))g.fillText(o.label.split(' ')[0],x+8,y-8)}
if(S.pt){const[x,y]=xy(S.pt.az,S.pt.alt);g.strokeStyle='#ff5c6c';g.lineWidth=2;g.beginPath();g.arc(x,y,10,0,7);g.moveTo(x-16,y);g.lineTo(x+16,y);g.moveTo(x,y-16);g.lineTo(x,y+16);g.stroke();g.lineWidth=1}}
// ---------- attitude + compass ----------
function att(){
const c=$('att'),g=c.getContext('2d'),R=94,cx=100,cy=100,o=S.st?.telemetry.orientation;g.clearRect(0,0,200,200);
if(!o){g.fillStyle='#7f8aa8';g.textAlign='center';g.fillText('no IMU data',cx,cy);return}
g.save();g.beginPath();g.arc(cx,cy,R-16,0,7);g.clip();g.translate(cx,cy);g.rotate(-o.roll*rad);g.translate(0,o.pitch*1.6);
g.fillStyle='#1b4b8f';g.fillRect(-200,-400,400,400);g.fillStyle='#6b4a2a';g.fillRect(-200,0,400,400);g.strokeStyle='#fff';g.beginPath();g.moveTo(-200,0);g.lineTo(200,0);g.stroke();g.restore();
g.save();g.translate(cx,cy);g.rotate(-o.yaw*rad);g.strokeStyle='#7f8aa8';g.textAlign='center';g.textBaseline='middle';g.font='13px system-ui';
for(let a=0;a<360;a+=10){g.save();g.rotate(a*rad);g.beginPath();g.moveTo(0,-R);g.lineTo(0,-R+(a%30?4:9));g.stroke();g.restore()}
[['N',0],['E',90],['S',180],['W',270]].forEach(([l,a])=>{g.save();g.rotate(a*rad);g.translate(0,-R+17);g.fillStyle=l==='N'?'#ff5c6c':'#e6ebf7';g.fillText(l,0,0);g.restore()});g.restore();
g.fillStyle=g.strokeStyle='#ffc247';g.beginPath();g.moveTo(cx,cy-R+1);g.lineTo(cx-5,cy-R-8);g.lineTo(cx+5,cy-R-8);g.fill();
g.lineWidth=2;g.beginPath();g.moveTo(cx-32,cy);g.lineTo(cx-10,cy);g.moveTo(cx+10,cy);g.lineTo(cx+32,cy);g.stroke()}
// ---------- recommendations / objects ----------
async function loadRecs(){try{const r=await j('/api/sky/recommend?limit=8');
$('recs').innerHTML=r.recommendations.length?'<tr class="mut"><th>Target</th><th>Alt</th><th>Mag</th><th>Why</th></tr>'+r.recommendations.map((x)=>`<tr class="rec ${x.spec===S.target?'sel':''}" data-spec="${x.spec}"><td>${x.label}<div class="mut">${x.type}</div></td><td>${x.alt}°</td><td>${x.mag??'–'}</td><td class="mut">${x.why.join(', ')}</td></tr>`).join(''):'<tr><td class="mut">Nothing above the horizon right now.</td></tr>'}
catch(e){$('recs').innerHTML=`<tr><td class="bad">${e.message}</td></tr>`}}
$('recs').onclick=(e)=>{const tr=e.target.closest('tr.rec');if(tr)setTarget(tr.dataset.spec)};
const loadObjs=async()=>{try{S.objs=(await j('/api/sky/objects')).objects}catch{S.objs=[]}};
capUI();loadObjs();loadRecs();refreshInfo();tick();
setInterval(tick,1000);setInterval(loadObjs,5000);setInterval(loadRecs,60000);setInterval(refreshInfo,15000);
</script></body></html>
+157 -28
View File
@@ -1,15 +1,20 @@
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { TelemetryService } from './services/telemetry.mjs'; import { TelemetryService } from './services/telemetry.mjs';
import { CameraService } from './services/camera.mjs'; import { CameraService } from './services/camera.mjs';
import { CelestialService } from './services/celestial.mjs'; import { CelestialService } from './services/celestial.mjs';
import { SkyService } from './services/sky.mjs';
const ROOT = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(ROOT, 'data')); const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(ROOT, 'data'));
const PORT = Number(process.env.PORT || 8080); const PORT = Number(process.env.PORT || 8080);
const HOST = process.env.HOST || '0.0.0.0'; const HOST = process.env.HOST || '0.0.0.0';
// UI lives in the project root. Override with UI_FILE if you keep it elsewhere (e.g. ui/index.html).
const UI_FILE = path.resolve(process.env.UI_FILE || path.join(ROOT, 'index.html'));
const log = Object.fromEntries(['debug', 'info', 'warn', 'error'].map((level) => [ const log = Object.fromEntries(['debug', 'info', 'warn', 'error'].map((level) => [
level, level,
@@ -43,9 +48,9 @@ const cameraConfig = {
vidBin: process.env.RPICAM_VID || 'rpicam-vid', vidBin: process.env.RPICAM_VID || 'rpicam-vid',
stillBin: process.env.RPICAM_STILL || 'rpicam-still', stillBin: process.env.RPICAM_STILL || 'rpicam-still',
ffmpegBin: process.env.FFMPEG || 'ffmpeg', ffmpegBin: process.env.FFMPEG || 'ffmpeg',
width: Number(process.env.W || 1280), width: Number(process.env.W || 720),
height: Number(process.env.H || 720), height: Number(process.env.H || 720),
fps: Number(process.env.FPS || 30), fps: Number(process.env.FPS || 60),
streamQuality: 85, streamQuality: 85,
streamMaxShutterUs: 1_000_000, streamMaxShutterUs: 1_000_000,
streamIdleStopMs: 5000, streamIdleStopMs: 5000,
@@ -81,6 +86,7 @@ if (process.env.DECLINATION_DEG !== undefined) {
const telemetry = new TelemetryService(telemetryConfig, { log }); const telemetry = new TelemetryService(telemetryConfig, { log });
const celestial = new CelestialService(celestialConfig, { telemetry, log, dataDir: DATA_DIR }); const celestial = new CelestialService(celestialConfig, { telemetry, log, dataDir: DATA_DIR });
const sky = new SkyService({ celestial, telemetry });
const camera = new CameraService(cameraConfig, { const camera = new CameraService(cameraConfig, {
log, log,
dataDir: DATA_DIR, dataDir: DATA_DIR,
@@ -106,6 +112,38 @@ function sendJson(res, status, data) {
res.end(JSON.stringify(data)); res.end(JSON.stringify(data));
} }
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.mp4': 'video/mp4',
'.h264': 'video/h264',
'.dng': 'image/x-adobe-dng',
};
async function serveFile(res, file, cache = 'no-cache') {
let st;
try {
st = await fsp.stat(file);
if (!st.isFile()) throw new Error('not a file');
} catch {
return sendJson(res, 404, { error: 'not_found' });
}
res.writeHead(200, {
'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
'Content-Length': st.size,
'Cache-Control': cache,
});
fs.createReadStream(file)
.on('error', (err) => res.destroy(err))
.pipe(res);
}
function readJson(req) { function readJson(req) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let body = ''; let body = '';
@@ -134,8 +172,35 @@ function readJson(req) {
async function handle(req, res) { async function handle(req, res) {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
const is = (method, pathname) => req.method === method && url.pathname === pathname;
if (req.method === 'GET' && url.pathname === '/') { // ───────────── UI + static ─────────────
if (is('GET', '/')) {
res.writeHead(302, { Location: '/index.html', 'Cache-Control': 'no-store' });
return res.end();
}
if (is('GET', '/index.html')) {
return serveFile(res, UI_FILE);
}
if (req.method === 'GET' && url.pathname.startsWith('/media/')) {
let rel;
try {
rel = decodeURIComponent(url.pathname.slice('/media/'.length));
} catch {
return sendJson(res, 400, { error: 'bad_path' });
}
const base = path.resolve(camera.mediaRoot);
const file = path.resolve(base, rel);
if (!file.startsWith(base + path.sep)) return sendJson(res, 403, { error: 'forbidden' });
return serveFile(res, file, 'public, max-age=3600');
}
// ───────────── API index ─────────────
if (is('GET', '/api')) {
return sendJson(res, 200, { return sendJson(res, 200, {
name: 'MRS', name: 'MRS',
status: 'online', status: 'online',
@@ -144,34 +209,29 @@ async function handle(req, res) {
'/api/status', '/api/status',
'/api/telemetry/battery', '/api/telemetry/battery',
'/api/camera/settings', '/api/camera/settings',
'/api/camera/files',
'/api/celestial/predict?target=',
'/api/celestial/delta?target=',
'/api/celestial/targets',
'/api/celestial/alignment',
'/api/sky/info?target=',
'/api/sky/recommend',
'/api/sky/objects',
], ],
}); });
} }
if (req.method === 'GET' && url.pathname === '/stream') { // ───────────── stream / telemetry ─────────────
if (is('GET', '/stream')) {
return camera.addStreamClient(req, res); return camera.addStreamClient(req, res);
} }
if (req.method === 'GET' && url.pathname === '/api/telemetry/battery') { if (is('GET', '/api/telemetry/battery')) {
return sendJson(res, 200, { battery: telemetry.getBattery() }); return sendJson(res, 200, { battery: telemetry.getBattery() });
} }
if (req.method === 'GET' && url.pathname === '/api/camera/settings') { if (is('GET', '/api/status')) {
return sendJson(res, 200, { settings: camera.getSettings() });
}
if (req.method === 'PATCH' && url.pathname === '/api/camera/settings') {
const body = await readJson(req);
const settings = await camera.applySettings(body);
return sendJson(res, 200, { ok: true, settings });
}
if (req.method === 'POST' && url.pathname === '/api/camera/settings/reset') {
const settings = await camera.resetSettings();
return sendJson(res, 200, { ok: true, settings });
}
if (req.method === 'GET' && url.pathname === '/api/status') {
let celestialStatus; let celestialStatus;
try { try {
@@ -189,7 +249,53 @@ async function handle(req, res) {
}); });
} }
if (req.method === 'GET' && url.pathname === '/api/celestial/predict') { // ───────────── camera settings ─────────────
if (is('GET', '/api/camera/settings')) {
return sendJson(res, 200, { settings: camera.getSettings() });
}
if (is('PATCH', '/api/camera/settings')) {
const body = await readJson(req);
const settings = await camera.applySettings(body);
return sendJson(res, 200, { ok: true, settings });
}
if (is('POST', '/api/camera/settings/reset')) {
const settings = await camera.resetSettings();
return sendJson(res, 200, { ok: true, settings });
}
// ───────────── camera capture jobs ─────────────
if (is('POST', '/api/camera/photo')) {
return sendJson(res, 200, await camera.takePhoto(await readJson(req)));
}
if (is('POST', '/api/camera/video/start')) {
return sendJson(res, 200, { ok: true, job: await camera.startVideo(await readJson(req)) });
}
if (is('POST', '/api/camera/video/stop')) {
return sendJson(res, 200, await camera.stopVideo());
}
if (is('POST', '/api/camera/timelapse/start')) {
return sendJson(res, 200, { ok: true, job: await camera.startTimelapse(await readJson(req)) });
}
if (is('POST', '/api/camera/timelapse/stop')) {
return sendJson(res, 200, await camera.stopTimelapse());
}
if (is('GET', '/api/camera/files')) {
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') || 50)));
return sendJson(res, 200, await camera.listFiles({ limit }));
}
// ───────────── celestial ─────────────
if (is('GET', '/api/celestial/predict')) {
const target = url.searchParams.get('target'); const target = url.searchParams.get('target');
if (!target) { if (!target) {
return sendJson(res, 400, { return sendJson(res, 400, {
@@ -200,7 +306,7 @@ async function handle(req, res) {
return sendJson(res, 200, celestial.predict(target)); return sendJson(res, 200, celestial.predict(target));
} }
if (req.method === 'GET' && url.pathname === '/api/celestial/delta') { if (is('GET', '/api/celestial/delta')) {
const target = url.searchParams.get('target'); const target = url.searchParams.get('target');
if (!target) { if (!target) {
return sendJson(res, 400, { return sendJson(res, 400, {
@@ -211,18 +317,18 @@ async function handle(req, res) {
return sendJson(res, 200, celestial.delta(target)); return sendJson(res, 200, celestial.delta(target));
} }
if (req.method === 'GET' && url.pathname === '/api/celestial/targets') { if (is('GET', '/api/celestial/targets')) {
const kind = url.searchParams.get('kind') || undefined; const kind = url.searchParams.get('kind') || undefined;
const q = url.searchParams.get('q') || undefined; const q = url.searchParams.get('q') || undefined;
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') || 100))); const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') || 100)));
return sendJson(res, 200, { targets: celestial.listTargets({ kind, q, limit }) }); return sendJson(res, 200, { targets: celestial.listTargets({ kind, q, limit }) });
} }
if (req.method === 'GET' && url.pathname === '/api/celestial/alignment') { if (is('GET', '/api/celestial/alignment')) {
return sendJson(res, 200, celestial.getAlignment()); return sendJson(res, 200, celestial.getAlignment());
} }
if (req.method === 'POST' && url.pathname === '/api/celestial/zero') { if (is('POST', '/api/celestial/zero')) {
const body = await readJson(req); const body = await readJson(req);
if (!body.target) { if (!body.target) {
return sendJson(res, 400, { return sendJson(res, 400, {
@@ -233,15 +339,38 @@ async function handle(req, res) {
return sendJson(res, 200, await celestial.zero(body.target)); return sendJson(res, 200, await celestial.zero(body.target));
} }
if (req.method === 'DELETE' && url.pathname === '/api/celestial/zero') { if (is('DELETE', '/api/celestial/zero')) {
return sendJson(res, 200, await celestial.clearZero()); return sendJson(res, 200, await celestial.clearZero());
} }
if (req.method === 'POST' && url.pathname === '/api/celestial/tle/refresh') { if (is('POST', '/api/celestial/tle/refresh')) {
const body = await readJson(req); const body = await readJson(req);
return sendJson(res, 200, { results: await celestial.refreshTLEs({ force: body.force !== false }) }); return sendJson(res, 200, { results: await celestial.refreshTLEs({ force: body.force !== false }) });
} }
// ───────────── sky (info / recommendations / overlay) ─────────────
if (is('GET', '/api/sky/info')) {
const target = url.searchParams.get('target');
if (!target) {
return sendJson(res, 400, {
error: 'missing_target',
message: 'Pass ?target=moon, saturn, vega, iss, radec:5.9,7.4, etc.',
});
}
return sendJson(res, 200, sky.info(target));
}
if (is('GET', '/api/sky/recommend')) {
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') || 8)));
const minAlt = Number(url.searchParams.get('minAlt') || 15);
return sendJson(res, 200, { recommendations: sky.recommend({ limit, minAlt }) });
}
if (is('GET', '/api/sky/objects')) {
return sendJson(res, 200, sky.objects());
}
return sendJson(res, 404, { error: 'not_found', path: url.pathname }); return sendJson(res, 404, { error: 'not_found', path: url.pathname });
} }
+131
View File
@@ -0,0 +1,131 @@
// 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 };
}
}