generated from ztimson/template
New UI
This commit is contained in:
+206
@@ -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 & 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 & 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}° 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>
|
||||
Reference in New Issue
Block a user