Files
eye-spy/index.html
T
2026-10-22 01:43:51 -04:00

291 lines
42 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:#080b12;--panel:#101621;--panel2:#0c111a;--line:#222c3c;--tx:#e8edf6;--mut:#8491a6;--ac:#397dff;--ok:#42d392;--bad:#ff5968;--warn:#ffc247}
*{box-sizing:border-box}html,body{width:100%;height:100%;margin:0}body{background:var(--bg);color:var(--tx);font:13px system-ui,sans-serif;display:flex;flex-direction:column;gap:8px;padding:8px;overflow:hidden}
#statusbar{height:42px;min-height:42px;display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr);align-items:center;gap:12px;padding:0 12px;background:#0d121b;border:1px solid #1d2736;border-radius:10px;font-variant-numeric:tabular-nums}
.status-side{display:flex;align-items:center;gap:14px;min-width:0}.status-side.right{justify-content:flex-end}.status-item{display:flex;align-items:center;gap:7px;white-space:nowrap}.status-center{display:flex;align-items:center;gap:9px;white-space:nowrap}.status-center strong{font-size:13px;font-weight:650}.status-center span{font-size:12px;color:var(--mut)}#statusCamera{font-size:11px;color:#b5c0d1;overflow:hidden;text-overflow:ellipsis}
.gps-icon{width:16px;height:16px;flex:none}.gps-item.no-fix{color:var(--bad)}.gps-item.has-fix{color:#fff}.gps-sats{font-size:12px;font-weight:650}
.battery-ring{width:30px;height:30px;position:relative;display:flex;align-items:center;justify-content:center;flex:none}.battery-ring svg{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg)}.battery-ring circle{fill:none;stroke-width:3.2}.battery-ring .track{stroke:#30394a}.battery-ring .level{stroke:#f0f3f8;stroke-linecap:round;transition:stroke-dasharray .25s,stroke .25s}.battery-ring.low .level{stroke:var(--bad)}.battery-ring.charging .level{stroke:var(--ac)}.battery-ring.charging::after{content:"";position:absolute;inset:5px;border:2px solid transparent;border-top-color:#397dff;border-radius:50%;animation:charge-spin 1s linear infinite}.battery-value{position:relative;z-index:1;font-size:8px;font-weight:750;line-height:1;color:#fff;display:flex;align-items:center;justify-content:center;width:100%;height:100%;text-align:center}.battery-ring.charging .battery-value{color:#fff}@keyframes charge-spin{to{transform:rotate(360deg)}}
#workspace{flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) minmax(290px,360px);gap:8px}#cameraArea{display:flex;flex-direction:column;gap:8px;min-width:0;min-height:0}#view{position:relative;flex:1;min-height:160px;width:100%;background:#000;border:1px solid var(--line);border-radius:12px;overflow:hidden}#view img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;z-index:0;pointer-events:none}#ov{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;z-index:1;pointer-events:auto;cursor:crosshair}#pip{position:absolute;top:12px;right:12px;width:min(24%,210px);height:auto;aspect-ratio:1;border:1px solid #28344a;border-radius:50%;background:#060a14dd}#viewTools{display:none;position:absolute;left:10px;right:10px;bottom:10px;justify-content:center;pointer-events:none}#viewTools #ovc{display:flex;align-items:center;justify-content:center;gap:5px;flex-wrap:wrap;pointer-events:auto;background:#0b111bdc;border:1px solid #283246;border-radius:9px;padding:5px;max-width:100%;backdrop-filter:blur(12px)}
button,input{font:inherit;color:var(--tx)}button{background:#171f2d;border:1px solid #2a3547;border-radius:7px;padding:6px 9px;cursor:pointer}button:hover:not(:disabled){border-color:var(--ac)}button:disabled{opacity:.35;cursor:not-allowed}button.p,.chip.on,.segs button.on{background:var(--ac);border-color:var(--ac)}.chip{border-radius:99px;padding:5px 10px;font-size:11px}.segs{display:flex;gap:4px}.segs button{flex:1;min-width:0;padding:6px 5px;font-size:12px}.ctl{margin:8px 0}.lh{display:flex;justify-content:space-between;align-items:center;gap:8px;color:var(--mut);margin-bottom:5px}.lh b{color:var(--tx);font-weight:600}input[type=text],input[type=number]{background:#0b111b;border:1px solid var(--line);border-radius:7px;padding:7px 8px;width:100%;min-width:0}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:#b5c0d1;margin:8px 0}.flips{display:flex;gap:4px;flex-wrap:wrap}.g2{display:grid;grid-template-columns:1fr 1fr;gap:6px}.big{font-size:20px;font-weight:600}.mut{color:var(--mut)}.ok{color:var(--ok)}.bad{color:var(--bad)}
#cameraDock{display:flex;flex-direction:column;gap:8px;align-items:stretch;padding:10px 12px;background:#101621;border:1px solid var(--line);border-radius:12px;max-height:42vh;overflow:auto;scrollbar-width:thin}#captureControls{display:grid;grid-template-columns:1fr;grid-template-areas:"settings" "fields" "modes" "shutter" "job";align-items:center;gap:7px;min-width:0;border:0;padding:0}#capTabs{grid-area:modes;width:min(100%,360px);justify-self:center}#capFields{grid-area:fields;width:min(100%,360px);justify-self:center}#capGo{grid-area:shutter;width:58px;height:58px;justify-self:center}#capJob{grid-area:job}#quickSettings{grid-area:settings;display:flex;align-items:center;justify-content:flex-start;gap:7px;flex-wrap:nowrap;overflow-x:auto;overflow-y:hidden;white-space:nowrap;scrollbar-width:none;padding:2px 1px;max-width:100%;touch-action:pan-x}#quickSettings::-webkit-scrollbar{display:none}#quickSettings button{flex:0 0 auto}#quickSettings button{background:#0b111b;border-color:#273247;border-radius:8px;min-width:68px;padding:7px 10px}#quickSettings button b{display:block;font-size:12px;color:#fff}#quickSettings button span{display:block;font-size:9px;color:var(--mut);text-transform:uppercase;letter-spacing:.06em}#capTabs{width:100%}#capFields{width:100%}#capFields .ctl{margin:5px 0}.shut{width:42px;height:42px;border-radius:50%;border:3px solid #e9edf4;background:#f04455;padding:0;flex:none}.shut.rec{border-radius:10px}#capJob{font-size:11px;text-align:center}#cameraSettings{min-width:0}#cameraSettings>.lh{display:none}#cameraSettings #cam{display:none}#cameraSettings.open #cam{display:grid}#skyCard .sky-layout{display:grid;grid-template-columns:minmax(0,1fr);gap:10px}#pip{position:relative;top:auto;right:auto;width:min(100%,260px);height:auto;aspect-ratio:1;border:1px solid #28344a;border-radius:50%;background:#060a14dd;justify-self:center}#skyCard #ovc{display:flex;gap:5px;flex-wrap:wrap}#skyCard .chip{background:#171f2d}#skyCard .chip.on{background:var(--ac)}#skyCard #bodyMenu{position:static;transform:none;display:none;flex-wrap:wrap;box-shadow:none;margin-top:7px}#skyCard #bodyMenu.open{display:flex}.mount-pad{position:absolute;z-index:5;pointer-events:auto;top:10px;left:10px;display:grid;grid-template-columns:repeat(3,28px);grid-template-rows:repeat(3,28px);gap:3px;padding:5px;background:#080b12a8;border:1px solid #ffffff30;border-radius:10px;backdrop-filter:blur(8px)}.mount-pad button{width:28px;height:28px;padding:0;background:#111827bb;border-color:#ffffff26}.mount-pad .center{font-size:10px;color:var(--mut)}.mount-pad .focus{font-size:9px;color:#b8c7df}.mount-pad .tracked{background:var(--ac);color:#fff;border-color:var(--ac)}#skyCard .overlay-group{background:transparent!important;border-radius:0;padding:8px 0}#skyCard .overlay-group .chip{background:#171f2d}#cameraSettings #cam{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 12px}#cameraSettings #cam details{grid-column:1/-1}#cameraSettings #cam .ctl{margin:5px 0}#cameraSettings #cam .ctl .lh{font-size:11px}#cameraSettings #cam .segs button{font-size:10px;padding:5px 2px}
#rightpanel{display:flex;flex-direction:column;gap:8px;min-width:0;min-height:0;overflow:auto;scrollbar-width:thin}section{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px;min-width:0;flex:none}h3{margin:0 0 10px;font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--mut);display:flex;align-items:center;justify-content:space-between;gap:8px}h3 small{text-transform:none;letter-spacing:0}.dpad{display:grid;grid-template-columns:repeat(3,34px);gap:4px}.dpad button{height:34px;padding:0}.mount-controls{display:grid;grid-template-columns:1fr 1fr;gap:8px 12px}.mount-controls>.lh{grid-column:1/-1}.mount-move{grid-column:1/-1;display:flex;align-items:center;justify-content:center;gap:20px;padding:2px 0}.mount-controls .ctl{margin:0}.controls-layout{display:flex;flex-direction:column;gap:12px}.camera-controls{border-top:1px solid var(--line);padding-top:10px}.target-info{display:grid;grid-template-columns:auto 1fr;gap:0 12px}.target-info>div{padding:5px 0;border-bottom:1px solid #253044}.target-info>div:nth-child(odd){color:var(--mut)}#tInfo .bad{margin-top:6px}#align{font-size:11px;line-height:1.4}table{width:100%;border-collapse:collapse;table-layout:auto}td,th{padding:7px 4px;text-align:left;font-weight:400;border-bottom:1px solid #253044;vertical-align:top}tr.rec{cursor:pointer}tr.rec:hover,tr.sel{background:#182438}#recs th{color:var(--mut);font-size:10px;text-transform:uppercase;letter-spacing:.05em}#recs td{font-size:12px}#recs td:last-child{color:var(--mut)}#toast{position:fixed;bottom:14px;left:14px;background:#2a1018;border:1px solid var(--bad);padding:8px 12px;border-radius:8px;display:none;z-index:9}
#bodyMenu{display:none;position:absolute;bottom:56px;left:50%;transform:translateX(-50%);padding:8px;background:#0b111bf2;border:1px solid #2a3547;border-radius:10px;box-shadow:0 10px 28px #0008;gap:5px;z-index:5;white-space:nowrap}#bodyMenu.open{display:flex}#bodyMenu .chip{background:#171f2d}#bodyMenu .chip.on{background:var(--ac)}#view{isolation:isolate}.pan-zone{position:absolute;z-index:2;pointer-events:auto;display:flex;align-items:center;justify-content:center;background:#0b111b88;color:#e8edf6;border:1px solid #ffffff1c;opacity:0;transition:opacity .15s;user-select:none;touch-action:none}.pan-zone:hover,#view.pan-active .pan-zone{opacity:.9}.pan-zone.n,.pan-zone.s{left:40%;right:40%;height:18%;cursor:ns-resize}.pan-zone.n{top:0;border-radius:0 0 9px 9px}.pan-zone.s{bottom:0;border-radius:9px 9px 0 0}.pan-zone.e,.pan-zone.w{top:35%;bottom:35%;width:12%;cursor:ew-resize}.pan-zone.e{right:0;border-radius:9px 0 0 9px}.pan-zone.w{left:0;border-radius:0 9px 9px 0}.pan-zone span{font-size:20px;text-shadow:0 1px 4px #000}
@media(min-width:1500px){#workspace{grid-template-columns:minmax(0,1fr) 360px}#cameraSettings #cam{grid-template-columns:repeat(4,minmax(0,1fr))}}
@media(max-width:1050px){body{height:auto;min-height:100%;overflow:auto}#workspace{grid-template-columns:minmax(0,1fr) 300px}#view{flex:none;aspect-ratio:4/3}#cameraDock{max-height:none}#cameraSettings #cam{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:760px){body{padding:6px;gap:6px}#statusbar{grid-template-columns:auto minmax(0,1fr);gap:8px;padding:0 8px}.status-side.left{display:none}#statusCamera{display:none}.status-center{justify-self:start;gap:6px}.status-center strong{font-size:12px}.status-center span{font-size:10px}.status-side.right{gap:10px}#workspace{display:flex;flex-direction:column}#view{aspect-ratio:1/1;min-height:0}#cameraDock{max-height:none;padding:9px}#captureControls{min-width:0}#capGo{width:52px;height:52px}#cameraSettings #cam{grid-template-columns:repeat(2,minmax(0,1fr))}#rightpanel{overflow:visible}#pip{width:28%}#viewTools{left:5px;right:5px;bottom:5px}#viewTools #ovc{gap:3px;padding:4px}.chip{padding:4px 8px}}
/* Compact camera-app controls and overlay settings */
#workspace{grid-template-columns:minmax(0,1fr) minmax(290px,360px)}
#cameraDock{max-height:none;overflow:visible;padding:10px 12px 12px}
#captureControls{display:flex;flex-direction:column;gap:9px}
#quickSettings{display:flex;flex-direction:row;flex-wrap:nowrap;justify-content:center;align-items:stretch;gap:6px;width:100%;min-width:0;max-width:100%;overflow-x:auto;overflow-y:hidden;white-space:nowrap;scrollbar-width:none;overscroll-behavior-x:contain;touch-action:pan-x;padding:2px 1px 7px}
#quickSettings button{flex:0 0 auto;min-width:66px;max-width:none;padding:7px 9px;background:#0b111b;border-color:#273247;text-align:center;white-space:normal}
#quickSettings button.active{border-color:var(--ac);background:#172947}
#quickSettings button span{display:block;font-size:9px;color:var(--mut);text-transform:uppercase;letter-spacing:.06em}
#quickSettings button b{display:block;font-size:12px;color:#fff;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#quickSettings::-webkit-scrollbar{display:none}
#captureRow{display:flex;align-items:center;justify-content:center;gap:18px}
#captureRow .shut{width:58px;height:58px;border-radius:50%;border:3px solid #f4f6fb;background:#f04455}
#captureRow .shut.rec{border-radius:12px}
.mode-cycle{width:42px;height:42px;border:0;border-radius:50%;background:#242b37;font-size:28px;line-height:1;padding:0}
.mode-label{text-align:center;color:#f5a623;font-size:10px;letter-spacing:.08em;margin-top:-5px}
#capFields{display:none;position:absolute;left:50%;bottom:100px;transform:translateX(-50%);z-index:8;width:min( min(600px, calc(100% - 24px)), 600px);padding:12px 16px;background:#111923ed;border:1px solid #ffffff20;border-radius:12px;backdrop-filter:blur(14px);box-shadow:0 10px 30px #0008}
#capFields.open{display:block}#capFields .ctl{margin:0}#capFields .lh{margin-bottom:8px}#settingOverlay{display:none;position:absolute;left:50%;bottom:.75rem;transform:translateX(-50%);z-index:20;pointer-events:auto;width:min(600px,calc(100% - 24px));padding:12px 16px;background:#111923ed;border:1px solid #ffffff20;border-radius:12px;backdrop-filter:blur(14px);box-shadow:0 10px 30px #0008}#settingOverlay.open{display:block}#settingOverlay .ctl{margin:0}#settingOverlay .setting-hidden{display:none!important}#settingOverlay .lh{margin-bottom:8px}#settingOverlay .segs{display:flex;flex-wrap:wrap;gap:5px}#settingOverlay input[type=range]{width:100%}#settingOverlay .flips{display:flex!important;gap:6px;flex-wrap:wrap}#settingOverlay button,#settingOverlay input{position:relative;z-index:1;pointer-events:auto}
#cameraSettings{display:none!important}
#cam{display:block!important}#cam .ctl{display:none!important}#cam .ctl.setting-open{display:block!important}
#cam details{display:none!important}#cam .flips{display:none!important}
#view{flex:1;min-height:240px}#view img,#ov{object-fit:contain}
#pip{display:block!important;position:relative;top:auto;right:auto;width:min(100%,260px);aspect-ratio:1;justify-self:center}
#skyCard .sky-layout{grid-template-columns:minmax(0,1fr)}#skyCard #ovc,#skyCard #bodyMenu{display:flex;gap:5px;flex-wrap:wrap;position:static;transform:none;box-shadow:none;background:transparent;border:0;padding:0;margin:0;white-space:normal}
.overlay-group{padding:8px 0;border-top:1px solid #253044}.group-label{font-size:10px;color:var(--mut);text-transform:uppercase;letter-spacing:.07em;margin-bottom:7px}
#skyCard .chip{background:#171f2d}#skyCard .chip.on{background:var(--ac)}
@media(max-width:760px){#quickSettings{gap:5px;justify-content:flex-start;flex-wrap:nowrap;overflow-x:auto}#quickSettings button{min-width:58px;padding:6px 7px}#capFields{bottom:92px}#captureRow{gap:14px}#captureRow .shut{width:54px;height:54px}}
</style></head><body>
<div id="statusbar" aria-label="Device status">
<div class="status-side left"><div class="status-item" id="statusCamera">Camera unavailable</div></div>
<div class="status-center"><strong id="clockStatus">--:--</strong><span id="dateStatus">---</span></div>
<div class="status-side right">
<div class="status-item gps-item no-fix" id="gpsItem"><svg class="gps-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-label="GPS"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M1 12h4M19 12h4"/></svg><span id="gpsSats" class="gps-sats">—</span></div>
<div class="status-item"><div id="batteryRing" class="battery-ring"><svg viewBox="0 0 36 36"><circle class="track" cx="18" cy="18" r="15"/><circle id="batteryLevel" class="level" cx="18" cy="18" r="15" pathLength="100" stroke-dasharray="0 100"/></svg><span id="batteryValue" class="battery-value">—%</span></div></div>
</div>
</div>
<div id="workspace">
<div id="cameraArea">
<div id="view">
<img id="stream" alt="live view"><canvas id="ov"></canvas><div id="capFields"></div><div id="settingOverlay"></div><div class="mount-pad"><button class="focus" data-focus="coarse-out" title="Coarse telescope focus out">C−</button><button data-pan="up">↑</button><button class="focus" data-focus="coarse-in" title="Coarse telescope focus in">C+</button><button data-pan="left">←</button><button class="center" id="trackToggle" title="Toggle target tracking" aria-label="Toggle target tracking"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M1 12h4M19 12h4"/></svg></button><button data-pan="right">→</button><button class="focus" data-focus="fine-out" title="Fine telescope focus out">F−</button><button data-pan="down">↓</button><button class="focus" data-focus="fine-in" title="Fine telescope focus in">F+</button></div>
</div>
<div id="cameraDock">
<div id="captureControls">
<div id="quickSettings"></div>
<div id="captureRow"><button class="mode-cycle" id="modePrev" title="Previous mode">‹</button><button class="shut" id="capGo" title="Capture"></button><button class="mode-cycle" id="modeNext" title="Next mode">›</button></div><div class="mode-label" id="modeLabel">PHOTO</div><div id="capJob" class="mut"></div>
</div>
<div id="cameraSettings"><div class="lh"><b>Camera settings</b><button id="camReset" style="padding:3px 8px">Reset</button></div><div id="cam"></div></div>
</div>
</div>
<div id="rightpanel">
<section id="skyCard"><h3>Sky map &amp; overlays</h3><div class="sky-layout"><canvas id="pip" width="420" height="420"></canvas><div><div class="overlay-group"><div class="group-label">Camera overlay</div><div id="ovc"></div></div><div class="overlay-group"><div class="group-label">Bodies</div><div id="bodyMenu"><button class="chip on" data-body="stars">Stars</button><button class="chip on" data-body="dso">Deep sky</button><button class="chip on" data-body="terrestrial">Planets / Moon / Sun</button></div></div></div></div></section>
<section id="targetCard"><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</button></div>
<div id="tInfo" style="margin-top:6px"></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>Recommended now</h3><table id="recs"></table></section>
</div>
</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:{az:0,alt:0,aligned:false,declination:{deg:0}},realPoint:false,delta:null,targetInfo:null,objs:[],constellationLines:[],target:ST('target',''),cap:'single',cam:null,st:null},O={...{labels:1,stars:1,dso:1,terrestrial:1,cross:1,err:1,map:1},...ST('ov',{})};
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';
// Edge taps and drags are ready for mount integration; this build has no mount command endpoint exposed.
const panHint=()=>toast('Mount hardware is not connected');
$('view').querySelectorAll('[data-pan],[data-focus]').forEach((zone)=>zone.addEventListener('click',(e)=>{e.stopPropagation();panHint()}));
let tracking=false;$('trackToggle').onclick=(e)=>{e.stopPropagation();tracking=!tracking;$('trackToggle').classList.toggle('tracked',tracking);$('trackToggle').title=tracking?'Disable target tracking':'Enable target tracking';toast(tracking?'Target tracking enabled in UI':'Target tracking disabled in UI')};
let panStart=null;
$('view').addEventListener('pointerdown',(e)=>{if(e.target!==$('ov'))return;panStart={x:e.clientX,y:e.clientY};e.target.setPointerCapture?.(e.pointerId)});
$('view').addEventListener('pointerup',(e)=>{if(!panStart)return;const dx=e.clientX-panStart.x,dy=e.clientY-panStart.y;panStart=null;if(Math.hypot(dx,dy)>18)panHint()});
$('view').addEventListener('pointercancel',()=>{panStart=null});
// ---------- 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,101,1,(x)=>Number(x)>100?'RAW':x],
['denoise','Denoise','seg',['auto','off','cdn_fast','cdn_hq']],['exposureMode','Exposure mode','seg',['normal','sport','long']],['rotation','Rotation','seg',[0,180]]];
const ALL=[...MAIN,...MORE];
const M={};
const ctl=(r)=>{const[k,l,t]=r;
if(t==='seg')return`<div class="ctl" data-setting="${k}"><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" data-setting="${k}"><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=ALL.map(ctl).join('')+`<div class="flips segs" data-setting="rotation-flips"><button data-k="hflip">Mirror Horizontal</button><button data-k="vflip">Mirror Vertical</button></div>`;
const fmtSetting=(r,s)=>{const k=r[0],v=s?.[k];if(k==='shutterUs')return fs(v);if(k==='iso')return v||'Auto';if(k==='ev')return (Number(v)>0?'+':'')+Number(v||0).toFixed(1);if(k==='autofocusMode')return v||'Auto';if(k==='lensPosition')return s?.autofocusMode==='manual'?(Number(v)||0).toFixed(1)+' D':'Auto';if(k==='awb'||k==='denoise'||k==='exposureMode')return String(v??'Auto');if(k==='quality')return String(v??'—');if(k==='rotation')return String(v??0)+'°';return r[6]?r[6](v??0):String(v??'—')};
const quickOrder=[...ALL.filter((r)=>!['quality','lensPosition','exposureMode'].includes(r[0])),...ALL.filter((r)=>r[0]==='quality')];$('quickSettings').innerHTML=quickOrder.map((r)=>{const k=r.key||r[0],label=r.label||r[1].replace('Exposure comp','EV').replace('White balance','WB').replace('Lens position','Lens').replace('JPEG quality','Compress');return `<button data-quick="${k}"><span>${label}</span><b id="quick_${k}">—</b></button>`}).join('');
let activeSetting=null;
function restoreSettings(){const fields=$('settingOverlay');document.querySelectorAll('#settingOverlay [data-setting]').forEach((el)=>{$('cam').append(el);el.classList.remove('setting-open','setting-hidden')});fields.classList.remove('open');fields.replaceChildren();activeSetting=null}
function openSetting(k){const fields=$('settingOverlay');if(activeSetting?.dataset.setting===k){restoreSettings();document.querySelectorAll('[data-quick]').forEach((b)=>b.classList.remove('active'));return}restoreSettings();document.querySelectorAll('[data-quick]').forEach((b)=>b.classList.remove('active'));const field=document.querySelector(`#cam [data-setting="${CSS.escape(k)}"]`);if(!field)return;activeSetting=field;field.classList.add('setting-open');fields.append(field);
if(k==='autofocusMode'){const lens=document.querySelector('#cam [data-setting="lensPosition"]');if(lens){lens.classList.add('setting-open');fields.append(lens);lens.classList.toggle('setting-hidden',S.cam?.autofocusMode!=='manual')}}
if(k==='shutterUs'){const exposure=document.querySelector('#cam [data-setting="exposureMode"]');if(exposure){exposure.classList.add('setting-open');fields.append(exposure)}}
if(k==='rotation'){const flips=document.querySelector('#cam [data-setting="rotation-flips"]');if(flips)fields.append(flips)}
fields.classList.add('open');document.querySelector(`[data-quick="${CSS.escape(k)}"]`)?.classList.add('active')}
$('quickSettings').addEventListener('click',(e)=>{const b=e.target.closest('[data-quick]');if(b)openSetting(b.dataset.quick)});
function paint(){const s=S.cam;if(!s)return;document.querySelectorAll('#cam [data-k], #settingOverlay [data-k]').forEach((el)=>{const k=el.dataset.k,v=s[k];if(el.type==='range'){const m=M[k];el.value=k==='quality'&&S.raw?101:(m.vals?m.vals.reduce((b,x,i)=>Math.abs(x-v)<Math.abs(m.vals[b]-v)?i:b,0):(v??0));const label=$('v_'+k);if(label)label.textContent=k==='quality'&&S.raw?'RAW':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 if(k==='hflip'||k==='vflip')el.classList.toggle('on',!!v)});ALL.forEach((r)=>{const el=$('quick_'+r[0]);if(el)el.textContent=r[0]==='quality'?(S.raw?'RAW':String(s.quality??'—')):fmtSetting(r,s)});const lens=document.querySelector('#settingOverlay [data-setting="lensPosition"]');if(lens)lens.classList.toggle('setting-hidden',s.autofocusMode!=='manual')}
const tmr={};function send(k,v){if(!S.cam)return;S.cam[k]=v;clearTimeout(tmr[k]);tmr[k]=setTimeout(async()=>{try{const result=await j('/api/camera/settings',{method:'PATCH',body:{[k]:v}});S.cam=result.settings;paint()}catch(error){toast(error.message)}},250)}
document.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;if(k==='quality'&&v>100){S.raw=true;$('v_quality').textContent='RAW';paint();return}if(k==='quality')S.raw=false;$('v_'+k).textContent=m.f(v);send(k,v);if(k==='autofocusMode')paint()});
document.addEventListener('change',(e)=>{const el=e.target;if(el.type==='range'&&el.dataset.k==='quality'&&+el.value>100){S.raw=true;paint()}});
document.addEventListener('click',(e)=>{const b=e.target.closest('button');if(!b)return;const k=(b.closest('[data-k]')||b).dataset.k;if(!S.cam)return;if(b.dataset.v!==undefined){if(!k)return;send(k,k==='rotation'?Number(b.dataset.v):b.dataset.v);paint()}else if(k==='hflip'||k==='vflip'){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:'',
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 MODES=['single','video','timelapse'],modeNames={single:'PHOTO',video:'VIDEO',timelapse:'TIMELAPSE'};const capUI=()=>{$('capFields').innerHTML=CAPF[S.cap];$('modeLabel').textContent=modeNames[S.cap];$('modePrev').textContent=modeNames[MODES[(MODES.indexOf(S.cap)+MODES.length-1)%MODES.length]].slice(0,1);$('modeNext').textContent=modeNames[MODES[(MODES.indexOf(S.cap)+1)%MODES.length]].slice(0,1);$('capFields').classList.remove('open')};const cycleMode=(d)=>{S.cap=MODES[(MODES.indexOf(S.cap)+d+MODES.length)%MODES.length];capUI()};$('modePrev').onclick=()=>cycleMode(-1);$('modeNext').onclick=()=>cycleMode(1);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:!!S.raw}});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){S.targetInfo=null;$('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;S.targetInfo=i;$('tName').textContent=i.target.label;
const kv=(a,b)=>`<div>${a}</div><div>${b??'—'}</div>`;
$('tInfo').innerHTML=`<div class="target-info">${kv('Altitude / Azimuth',i.alt+'° / '+i.az+'°')}${i.magnitude!=null?kv('Magnitude',i.magnitude)+kv('Distance',i.distanceKm!=null?(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+'% · '+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.realPoint=!!(p&&p.az!=null);S.pt=S.realPoint?p:{az:0,alt:0,aligned:false,declination:{deg:0}};render(S.st)}catch{return}
if(S.target&&S.realPoint)try{S.delta=await j('/api/celestial/delta?target='+encodeURIComponent(S.target))}catch{S.delta=null}else S.delta=null;
draw();pip()}
function render(st){
const t=st.telemetry||{},c=st.camera||{},b=t.battery||{},g=t.gps||{},a=st.celestial?.alignment||{};
const cs=c.settings||{};
if(!S.cam&&Object.keys(cs).length){S.cam=cs;paint()}
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);$('capGo').title=jb?'Stop '+jb.type:'Capture';
$('align').textContent=a.aligned?`Aligned on ${a.target?.label} at ${new Date(a.createdAt).toLocaleTimeString()}`:'Not aligned: centre a known object and press Zero.';
const fix=String(g?.state||'').startsWith('fix'),sat=g?.satellites;
const batteryRing=$('batteryRing');
batteryRing.classList.toggle('low',b.available&&b.percent<20);batteryRing.classList.toggle('charging',!!b.charging);
const batteryPct=b?.available?Math.max(0,Math.min(100,Number(b.percent)||0)):0;
$('batteryLevel').setAttribute('stroke-dasharray',`${batteryPct} 100`);
$('batteryValue').textContent=b?.available?`${Math.round(batteryPct)}%`:'—';
batteryRing.title=b?.available?`${b.voltageV} V${b.charging?' · charging':''}`:'Battery unavailable';
$('gpsItem').className='status-item gps-item '+(fix?'has-fix':'no-fix');
$('gpsSats').textContent=String(sat?.seen??sat?.used??0);$('gpsSats').title=`${sat?.used??0} used / ${sat?.seen??0} seen · ${String(g?.state||'unknown').replaceAll('_',' ')}`;
const now=new Date(t?.time?.epochMs||Date.now());
$('clockStatus').textContent=now.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'});
$('dateStatus').textContent='· '+now.toLocaleDateString([], {weekday:'short',month:'short',day:'numeric'});
const stream=c.stream||{};$('statusCamera').textContent=`${stream.running?`${stream.width}×${stream.height} · ${Number(stream.fps||0).toFixed(0)} fps`:'Stream idle'} · ${fs(cs.shutterUs)} · ISO ${cs.iso||'Auto'}`;
}
// Fixed field of view keeps overlays testable without IMU hardware; no synthetic bodies are injected.
const fovDeg=()=>8;
const OVL=[['cross','Crosshair'],['err','Error line'],['labels','Labels'],['map','Sky overlay']];
$('ovc').innerHTML=OVL.map(([k,l])=>`<button class="chip ${O[k]?'on':''}" data-k="${k}">${l}</button>`).join('');
function syncBodyMenu(){$$('bodyMenu button').forEach((b)=>b.classList.toggle('on',!!O[b.dataset.body]))}
syncBodyMenu();
function $$(selector){return document.querySelectorAll(selector)}
$('ovc').onclick=(e)=>{const b=e.target.closest('button');if(!b)return;const k=b.dataset.k;O[k]=O[k]?0:1;SV('ov',O);b.classList.toggle('on',!!O[k]);draw();pip()};
$('bodyMenu').onclick=(e)=>{const b=e.target.closest('button[data-body]');if(!b)return;const k=b.dataset.body;O[k]=O[k]?0:1;SV('ov',O);b.classList.toggle('on',!!O[k]);draw();pip()};
function objectKind(o){
const type=String(o.type||o.category||'').toLowerCase();
if(/star|stellar/.test(type))return 'stars';
if(/galaxy|nebula|cluster|globular|deep.?sky|\bdso\b/.test(type))return 'dso';
if(/planet|moon|sun|terrestrial/.test(type))return 'terrestrial';
return 'other';
}
function objectPosition(o){
const az=Number(o.az??o.azimuth),alt=Number(o.alt??o.altitude);
return Number.isFinite(az)&&Number.isFinite(alt)?{az,alt}:null;
}
function draw(){
const c=$('ov'),g=c.getContext('2d'),W=c.width=Math.max(1,c.clientWidth),H=c.height=Math.max(1,c.clientHeight);
g.clearRect(0,0,W,H);
const P=S.pt||{az:0,alt:0},ppd=W/fovDeg(),cx=W/2,cy=H/2;
const 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';g.lineWidth=1.5;
// Draw faint constellation segments when the sky API supplies explicit line pairs,
// or star objects carry constellation + line/order metadata.
if(O.map&&O.stars){
g.save();g.strokeStyle='rgba(160,190,235,.28)';g.lineWidth=1;g.setLineDash([]);
for(const line of S.constellationLines){
const a=objectPosition(line.from||line.start||line[0]),b=objectPosition(line.to||line.end||line[1]);
if(!a||!b)continue;const [x1,y1]=proj(a),[x2,y2]=proj(b);
if(Math.max(x1,x2)<0||Math.min(x1,x2)>W||Math.max(y1,y2)<0||Math.min(y1,y2)>H)continue;
g.beginPath();g.moveTo(x1,y1);g.lineTo(x2,y2);g.stroke();
}
const groups=new Map();
for(const o of S.objs){if(objectKind(o)!=='stars')continue;const name=o.constellation||o.constellationName||o.constellationId;const order=Number(o.constellationOrder??o.lineOrder??o.sequence);
if(name&&Number.isFinite(order)){if(!groups.has(String(name)))groups.set(String(name),[]);groups.get(String(name)).push({o,order})}}
for(const stars of groups.values()){stars.sort((a,b)=>a.order-b.order);for(let i=1;i<stars.length;i++){const a=objectPosition(stars[i-1].o),b=objectPosition(stars[i].o);if(!a||!b)continue;const[x1,y1]=proj(a),[x2,y2]=proj(b);g.beginPath();g.moveTo(x1,y1);g.lineTo(x2,y2);g.stroke()}}
g.restore();
}
for(const o of S.objs){
const pos=objectPosition(o);if(!pos)continue;
const kind=objectKind(o),visible=!!O.map&&(kind==='other'?true:!!O[kind]);if(!visible)continue;
const[x,y]=proj(pos);if(x<-40||y<-20||x>W+40||y>H+20)continue;
const label=String(o.label||o.name||o.spec||o.type||'Object');
g.strokeStyle=g.fillStyle=kind==='stars'?'#dce8ff':kind==='dso'?'#ffc247':kind==='terrestrial'?'#8fb4ff':'#c5d0e6';
g.beginPath();
if(kind==='stars'){g.arc(x,y,Math.max(1.5,3.5-(Number(o.magnitude)||2)/2),0,Math.PI*2);g.fill()}
else if(kind==='dso'){g.ellipse(x,y,8,5,0,0,Math.PI*2);g.stroke()}
else{g.arc(x,y,6,0,Math.PI*2);g.stroke()}
if(O.labels&&O.map){g.fillStyle='#e6ebf7';g.fillText(label,x+10,y)}
}
if(O.cross){g.strokeStyle='#ff5c6caa';g.beginPath();g.moveTo(cx-22,cy);g.lineTo(cx+22,cy);g.moveTo(cx,cy-22);g.lineTo(cx,cy+22);g.stroke()}
if(O.err){
const d=S.delta,rawTarget=d?.target||null;
const target=rawTarget?.target?{...rawTarget,...rawTarget.target,label:rawTarget.target.label}:rawTarget||
(S.targetInfo?{az:S.targetInfo.az,alt:S.targetInfo.alt,label:S.targetInfo.target?.label}:null);
if(target&&Number.isFinite(Number(target.az))&&Number.isFinite(Number(target.alt))){
let[x,y]=proj({az:Number(target.az),alt:Number(target.alt)});
const off=x<0||y<0||x>W||y>H;
g.strokeStyle=g.fillStyle=d?.onTarget?'#42d392':'#ffc247';g.setLineDash([6,5]);g.beginPath();g.moveTo(cx,cy);
if(off){const k=.45*W/Math.max(1,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,10,0,Math.PI*2);g.stroke();
g.fillText(`${target.label||S.target||'Target'}${off?' (off-screen)':''}`,x+14,y);
}else if(S.target&&O.err){
g.strokeStyle='#ffc247';g.setLineDash([6,5]);g.beginPath();g.moveTo(cx,cy);g.lineTo(cx+Math.min(W,H)*.3,cy-Math.min(W,H)*.2);g.stroke();g.setLineDash([]);
}
}
}
// Select the nearest rendered sky object; labels are clickable too.
$('ov').style.cursor='crosshair';$('pip').style.cursor='crosshair';
$('view').addEventListener('click',(e)=>{
if(e.target.closest('button, #settingOverlay, #capFields, .mount-pad'))return;
if(!O.map||!S.objs.length)return;
const c=$('ov'),r=c.getBoundingClientRect(),W=c.width,H=c.height,P=S.pt||{az:0,alt:0},ppd=W/fovDeg(),cx=W/2,cy=H/2;
const x=(e.clientX-r.left)*W/r.width,y=(e.clientY-r.top)*H/r.height;
const project=(o)=>[cx+wrap180(o.az-P.az)*Math.cos(P.alt*rad)*ppd,cy-(o.alt-P.alt)*ppd];
let best=null,dist=65;
for(const o of S.objs){const pos=objectPosition(o);if(!pos)continue;const kind=objectKind(o);if(!O.map||(kind==='stars'&&!O.stars)||(kind==='dso'&&!O.dso)||(kind==='terrestrial'&&!O.terrestrial))continue;
const [ox,oy]=project(pos);if(ox<-40||oy<-20||ox>W+40||oy>H+20)continue;
const markerDist=Math.hypot(x-ox,y-oy),label=String(o.label||o.name||o.spec||o.type||'Object');
const inLabel=O.labels&&x>=ox+7&&x<=ox+12+label.length*7&&y>=oy-10&&y<=oy+10;const d=inLabel?0:markerDist;
if(d<dist){dist=d;best=o}}
if(!best){toast('Click closer to a visible sky marker or label');return}
if(!best){toast('Click closer to a visible sky marker or label');return}const target=best.spec||best.name||best.label;if(!target)return;setTarget(String(target));toast(`Target: ${best.label||best.name||target}`);
});
$('pip').addEventListener('click',(e)=>{const c=$('pip'),r=c.getBoundingClientRect(),x=(e.clientX-r.left)*420/r.width,y=(e.clientY-r.top)*420/r.height,cx=210,cy=210,R=185;let best=null,dist=18;const xy=(az,alt)=>{const rr=(90-Math.max(alt,0))/90*R;return[cx-rr*Math.sin(az*rad),cy-rr*Math.cos(az*rad)]};for(const o of S.objs){const pos=objectPosition(o);if(!pos||pos.alt<0)continue;const kind=objectKind(o);if(kind!=='other'&&!O[kind])continue;const [ox,oy]=xy(pos.az,pos.alt),d=Math.hypot(x-ox,y-oy);if(d<dist){dist=d;best=o}}if(best){const target=best.spec||best.name||best.label;if(target){setTarget(String(target));toast(`Target: ${best.label||best.name||target}`)}}});
function pip(){
const c=$('pip');c.style.display='block';const g=c.getContext('2d'),R=185,cx=210,cy=210;g.clearRect(0,0,420,420);
g.strokeStyle='#283246';g.fillStyle='#8491a6';g.font='20px system-ui';g.textAlign='center';[R,R*2/3,R/3].forEach((r)=>{g.beginPath();g.arc(cx,cy,r,0,Math.PI*2);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='13px system-ui';
for(const o of S.objs){const pos=objectPosition(o);if(!pos||pos.alt<0)continue;const kind=objectKind(o);if(kind!=='other'&&!O[kind])continue;const[x,y]=xy(pos.az,pos.alt),t=o.spec===S.target,label=String(o.label||o.name||o.spec||'');g.fillStyle=t||kind==='dso'?'#ffc247':kind==='terrestrial'?'#8fb4ff':'#cfd8f5';g.beginPath();if(kind==='dso')g.rect(x-3,y-3,6,6);else g.arc(x,y,t?6:kind==='stars'?2:4,0,Math.PI*2);g.fill();if(O.labels&&(t||kind!=='stars'))g.fillText(label.split(' ')[0],x+8,y-8)}
if(S.pt){const[x,y]=xy(S.pt.az,S.pt.alt);g.strokeStyle='#ff5968';g.lineWidth=2;g.beginPath();g.arc(x,y,10,0,Math.PI*2);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}}
// ---------- 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{const r=await j('/api/sky/objects');S.objs=Array.isArray(r.objects)?r.objects:[];S.constellationLines=Array.isArray(r.constellationLines)?r.constellationLines:[];draw();pip()}catch(e){S.objs=[];S.constellationLines=[];draw();pip()}};
capUI();loadObjs();loadRecs();refreshInfo();tick();
setInterval(tick,1000);setInterval(loadObjs,5000);setInterval(loadRecs,60000);setInterval(refreshInfo,15000);
</script></body></html>