updated ui

This commit is contained in:
2026-10-22 01:43:51 -04:00
parent fb5cd8fcdc
commit e71c877214
+220 -136
View File
@@ -3,81 +3,97 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>MRS Telescope</title> <title>MRS Telescope</title>
<style> <style>
:root{--bg:#06080e;--card:#0c111d;--line:#1b2440;--tx:#e6ebf7;--mut:#7f8aa8;--ac:#2f7bff;--ok:#3ddc84;--bad:#ff5c6c;--warn:#ffc247} :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} *{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}
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} #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}
#view{position:relative;height:calc(100vh - 12px);aspect-ratio:1;background:#000;border:1px solid var(--line);border-radius:10px;overflow:hidden} .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}
#view img,#ov{position:absolute;inset:0;width:100%;height:100%;object-fit:cover} .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}
.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)} .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)}}
#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} #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)}
#pip{position:absolute;top:10px;right:10px;width:210px;height:210px;border:1px solid var(--line);border-radius:50%;background:#060a14dd} 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)}
#ovc{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);display:flex;gap:4px;padding:4px 6px} #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}
.col{display:flex;flex-direction:column;gap:6px;overflow:auto;scrollbar-width:none;min-width:0}.col::-webkit-scrollbar{display:none} #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}
section{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 12px;flex:none} #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}
h3{margin:0 0 8px;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);display:flex;justify-content:space-between} @media(min-width:1500px){#workspace{grid-template-columns:minmax(0,1fr) 360px}#cameraSettings #cam{grid-template-columns:repeat(4,minmax(0,1fr))}}
h3 small{text-transform:none;letter-spacing:0} @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))}}
input,button{font:inherit;color:var(--tx)} @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}}
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} /* Compact camera-app controls and overlay settings */
button:hover:not(:disabled){border-color:var(--ac)}button:disabled{opacity:.35;cursor:not-allowed} #workspace{grid-template-columns:minmax(0,1fr) minmax(290px,360px)}
button.p{background:var(--ac);border-color:var(--ac)} #cameraDock{max-height:none;overflow:visible;padding:10px 12px 12px}
.chip{border-radius:99px;padding:3px 10px;font-size:12px}.chip.on,.segs button.on{background:var(--ac);border-color:var(--ac)} #captureControls{display:flex;flex-direction:column;gap:9px}
.segs{display:flex;gap:3px}.segs button{flex:1;padding:4px 2px;font-size:12px} #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}
.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} #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}
input[type=range]{width:100%;accent-color:var(--ac);height:18px;margin:0} #quickSettings button.active{border-color:var(--ac);background:#172947}
#cam.af input[data-k=lensPosition]{opacity:.35;pointer-events:none} #quickSettings button span{display:block;font-size:9px;color:var(--mut);text-transform:uppercase;letter-spacing:.06em}
details summary{cursor:pointer;color:var(--mut);margin:8px 0}.flips{display:flex;gap:4px;flex-wrap:wrap} #quickSettings button b{display:block;font-size:12px;color:#fff;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.g2{display:grid;grid-template-columns:1fr 1fr;gap:6px}.tile{background:#0f1626;border:1px solid var(--line);border-radius:8px;padding:8px} #quickSettings::-webkit-scrollbar{display:none}
.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)} #captureRow{display:flex;align-items:center;justify-content:center;gap:18px}
.dpad{display:grid;grid-template-columns:repeat(3,38px);gap:4px}.dpad button{height:38px;padding:0} #captureRow .shut{width:58px;height:58px;border-radius:50%;border:3px solid #f4f6fb;background:#f04455}
.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} #captureRow .shut.rec{border-radius:12px}
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} .mode-cycle{width:42px;height:42px;border:0;border-radius:50%;background:#242b37;font-size:28px;line-height:1;padding:0}
#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} .mode-label{text-align:center;color:#f5a623;font-size:10px;letter-spacing:.08em;margin-top:-5px}
@media(max-width:1000px){body{grid-template-columns:1fr;height:auto;overflow:auto}#view{height:auto;width:100%}} #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> </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"> <div id="view">
<img id="stream" alt="live view"><canvas id="ov"></canvas> <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 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"> </div>
<section><h3>Target <small id="tName"></small></h3> <div id="cameraDock">
<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="captureControls">
<div id="tInfo" style="margin-top:8px"></div> <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> <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> <section><h3>Recommended now</h3><table id="recs"></table></section>
</div> </div>
</div>
<div id="toast"></div> <div id="toast"></div>
<script> <script>
const $=(i)=>document.getElementById(i),rad=Math.PI/180,wrap180=(a)=>((a+540)%360)-180; 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 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 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 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 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 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'}):'—'; const tm=(s)=>s?new Date(s).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'}):'—';
$('stream').src='/stream'; $('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) ---------- // ---------- 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 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);
@@ -85,35 +101,42 @@ 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'], 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'], ['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']]]; ['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], 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]]]; ['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 M={};
const ctl=(r)=>{const[k,l,t]=r; 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>`; 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]; 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>`}; 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=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>`; $('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>`;
function paint(){const s=S.cam;document.querySelectorAll('#cam [data-k]').forEach((el)=>{const k=el.dataset.k,v=s[k]; 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??'—')};
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)} 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('');
else if(el.classList.contains('segs'))el.querySelectorAll('button').forEach((b)=>b.classList.toggle('on',b.dataset.v==String(v))); let activeSetting=null;
else el.classList.toggle('on',!!v)});$('cam').classList.toggle('af',s.autofocusMode!=='manual')} 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}
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)} 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);
$('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)}); 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')}}
$('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(k==='shutterUs'){const exposure=document.querySelector('#cam [data-setting="exposureMode"]');if(exposure){exposure.classList.add('setting-open');fields.append(exposure)}}
if(b.dataset.v!==undefined)send(k,k==='rotation'?Number(b.dataset.v):b.dataset.v);else send(k,!S.cam[k]);paint()}); 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()}); $('camReset').onclick=act(async()=>{S.cam=(await j('/api/camera/settings/reset',{method:'POST',body:{}})).settings;paint()});
// ---------- capture ---------- // ---------- capture ----------
const CAPF={single:`<div class="ctl lh"><span>Also save RAW (.dng)</span><input type="checkbox" id="raw"></div>`, 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>`, 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>`}; 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))}; 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();
$('capTabs').onclick=(e)=>{if(e.target.dataset.m){S.cap=e.target.dataset.m;capUI()}};capUI();
const nv=(i)=>Number($(i).value); const nv=(i)=>Number($(i).value);
$('capGo').onclick=act(async()=>{const job=S.st?.camera?.job; $('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==='video')return j('/api/camera/video/stop',{method:'POST',body:{}});
if(job?.type==='timelapse')return j('/api/camera/timelapse/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)} 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 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')}})}); else await j('/api/camera/video/start',{method:'POST',body:{durationSec:nv('vD'),fps:nv('vF')}})});
@@ -123,84 +146,145 @@ $('tSet').onclick=()=>setTarget($('tIn').value.trim());
function setTarget(t){S.target=t;SV('target',t);$('tIn').value=t;S.delta=null;refreshInfo();loadRecs();tick()} 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??'—'}°`)}); $('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'})); $('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} 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;$('tName').textContent=i.target.label; 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 class="mut">${a}</div><div>${b}</div>`; const kv=(a,b)=>`<div>${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+'°'):''} $('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>'}`}
${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>`}} catch(e){$('tInfo').innerHTML=`<div class="bad">${e.message}</div>`}}
// ---------- status polling / render ---------- // ---------- 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>`; 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(){ 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'} 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.pt)try{S.delta=await j('/api/celestial/delta?target='+encodeURIComponent(S.target))}catch{S.delta=null}else S.delta=null; 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();att()} draw();pip()}
function render(st){ 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; const t=st.telemetry||{},c=st.camera||{},b=t.battery||{},g=t.gps||{},a=st.celestial?.alignment||{};
$('warn').style.display=S.pt?'none':'block'; const cs=c.settings||{};
$('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||''}`; if(!S.cam&&Object.keys(cs).length){S.cam=cs;paint()}
$('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}`:''); 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; $('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.'; $('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 fix=String(g?.state||'').startsWith('fix'),sat=g?.satellites;
const o=t.orientation,n=(k,v,u='°')=>`<div class="lh"><span>${k}</span><b>${v}${u}</b></div>`; const batteryRing=$('batteryRing');
$('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):''); batteryRing.classList.toggle('low',b.available&&b.percent<20);batteryRing.classList.toggle('charging',!!b.charging);
const pct=b.available?b.percent:0,bc=pct<20?'#ff5c6c':pct<50?'#ffc247':'#3ddc84',fix=g.state.startsWith('fix'); const batteryPct=b?.available?Math.max(0,Math.min(100,Number(b.percent)||0)):0;
const dots=g.satellites?Array.from({length:g.satellites.seen},(_,i)=>`<i class="${i<g.satellites.used?'u':''}"></i>`).join(''):''; $('batteryLevel').setAttribute('stroke-dasharray',`${batteryPct} 100`);
$('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> $('batteryValue').textContent=b?.available?`${Math.round(batteryPct)}%`:'—';
<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> batteryRing.title=b?.available?`${b.voltageV} V${b.charging?' · charging':''}`:'Battery unavailable';
<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> $('gpsItem').className='status-item gps-item '+(fix?'has-fix':'no-fix');
<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> $('gpsSats').textContent=String(sat?.seen??sat?.used??0);$('gpsSats').title=`${sat?.used??0} used / ${sat?.seen??0} seen · ${String(g?.state||'unknown').replaceAll('_',' ')}`;
<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> const now=new Date(t?.time?.epochMs||Date.now());
<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>`} $('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'}`;
}
// ---------- view overlays ---------- // Fixed field of view keeps overlays testable without IMU hardware; no synthetic bodies are injected.
const fovDeg=()=>0.2*Math.pow(150,S.fovV/100); const fovDeg=()=>8;
$('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=[['cross','Crosshair'],['err','Error line'],['labels','Labels'],['map','Sky overlay']];
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').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 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(){ 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); const c=$('ov'),g=c.getContext('2d'),W=c.width=Math.max(1,c.clientWidth),H=c.height=Math.max(1,c.clientHeight);
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.clearRect(0,0,W,H);
g.font='12px system-ui';g.textBaseline='middle'; const P=S.pt||{az:0,alt:0},ppd=W/fovDeg(),cx=W/2,cy=H/2;
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 proj=(o)=>[cx+wrap180(o.az-P.az)*Math.cos(P.alt*rad)*ppd,cy-(o.alt-P.alt)*ppd];
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)} g.font='12px system-ui';g.textBaseline='middle';g.lineWidth=1.5;
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()} // Draw faint constellation segments when the sky API supplies explicit line pairs,
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); // or star objects carry constellation + line/order metadata.
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)}} if(O.map&&O.stars){
function pip(){ g.save();g.strokeStyle='rgba(160,190,235,.28)';g.lineWidth=1;g.setLineDash([]);
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); for(const line of S.constellationLines){
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()}); const a=objectPosition(line.from||line.start||line[0]),b=objectPosition(line.to||line.end||line[1]);
[['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)); if(!a||!b)continue;const [x1,y1]=proj(a),[x2,y2]=proj(b);
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'; if(Math.max(x1,x2)<0||Math.min(x1,x2)>W||Math.max(y1,y2)<0||Math.min(y1,y2)>H)continue;
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)} g.beginPath();g.moveTo(x1,y1);g.lineTo(x2,y2);g.stroke();
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}} }
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}`);
});
// ---------- attitude + compass ---------- $('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 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); function pip(){
if(!o){g.fillStyle='#7f8aa8';g.textAlign='center';g.fillText('no IMU data',cx,cy);return} 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.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.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()});
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(); [['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));
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'; 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(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()} 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)}
[['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(); 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}}
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 ---------- // ---------- recommendations / objects ----------
async function loadRecs(){try{const r=await j('/api/sky/recommend?limit=8'); 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>'} $('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>`}} 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)}; $('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=[]}}; 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(); capUI();loadObjs();loadRecs();refreshInfo();tick();
setInterval(tick,1000);setInterval(loadObjs,5000);setInterval(loadRecs,60000);setInterval(refreshInfo,15000); setInterval(tick,1000);setInterval(loadObjs,5000);setInterval(loadRecs,60000);setInterval(refreshInfo,15000);
</script></body></html> </script></body></html>