Files
eye-spy/index.html
T
2026-10-21 22:17:35 -04:00

207 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>