// 飛行儀表 HUD(左下角):姿態儀(ADI)+ 地速/爬升率/高度/航向 + GPS/電池/風/
// 氣壓計/IMU 等真實機載硬體數據。資料源 /api/telemetry/hud(adapter 背景快取,
// 4Hz 輪詢);畫面用 rAF 對輪詢值做阻尼內插,指針/地平線不跳格。
(function () {
var T = {}; // 最新遙測(輪詢目標值)
var D = { roll: 0, pitch: 0, hdg: 0, gs: 0, vs: 0, alt: 0 }; // 顯示值(內插)
var lastDraw = 0;
function el(html) { var d = document.createElement('div'); d.innerHTML = html; return d.firstChild; }
function build() {
var css = document.createElement('style');
css.textContent =
'#zy-hud{position:fixed;left:14px;bottom:14px;width:308px;z-index:99998;' +
'font-family:system-ui,"Noto Sans TC",sans-serif;color:#dfe6f2;font-size:12px;' +
'background:rgba(12,17,28,.92);border:1px solid #2a3550;border-radius:10px;' +
'box-shadow:0 6px 24px rgba(0,0,0,.45);overflow:hidden}' +
'#zy-hud-hd{background:#18233c;padding:7px 10px;font-weight:700;cursor:pointer;' +
'display:flex;justify-content:space-between}' +
'#zy-hud-hd small{color:#8fa1c7;font-weight:400}' +
'#zy-hud.min #zy-hud-bd{display:none}' +
'#zy-hud-bd{padding:8px}' +
'#zy-hud-top{display:flex;gap:8px;align-items:center}' +
'.zy-big{flex:1;display:grid;grid-template-columns:1fr 1fr;gap:4px}' +
'.zy-cell{background:#141c2f;border:1px solid #232e49;border-radius:6px;padding:4px 6px}' +
'.zy-cell b{display:block;font-size:9.5px;color:#7f92b8;font-weight:600}' +
'.zy-cell span{font-size:15px;font-weight:700;font-variant-numeric:tabular-nums}' +
'.zy-cell small{font-size:9px;color:#7f92b8}' +
'#zy-hud-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;margin-top:6px}' +
'#zy-hud-grid .zy-cell span{font-size:12.5px}' +
'#zy-hud-raw{margin-top:6px;font-family:ui-monospace,monospace;font-size:9.5px;' +
'color:#93a5c8;line-height:1.5;background:#101828;border-radius:6px;padding:4px 6px}' +
// 前向超音波測距條:綠(淨空)/橘(警告≤8m)/紅閃(迴避中≤3.5m)
'#zy-us-bar{margin-top:6px;padding:4px 8px;border-radius:6px;font-size:11.5px;' +
'font-weight:700;background:#123521;color:#8fe8ab;transition:background .2s,color .2s}' +
'#zy-us-bar.zy-us-warn{background:#3a2a10;color:#ffc266}' +
'#zy-us-bar.zy-us-crit{background:#4a1414;color:#ff8080;animation:zyUsBlink 0.6s infinite}' +
'@keyframes zyUsBlink{50%{opacity:.45}}' +
'#zy-sam-bar{margin-top:4px;padding:4px 8px;border-radius:6px;font-size:11.5px;' +
'font-weight:700;background:#123521;color:#8fe8ab}' +
'#zy-sam-bar.zy-sam-alert{background:#4a1414;color:#ff8080;animation:zyUsBlink 0.5s infinite}' +
// FPV 眼鏡 OSD:滿版透明疊加;FPV 模式時隱藏左下儀表面板改用 OSD
'#zy-osd{position:fixed;inset:0;z-index:99996;pointer-events:none;display:none}' +
'body.zy-fpv #zy-osd{display:block}' +
'body.zy-fpv #zy-hud{display:none}' +
// FPV 時收掉會擋 OSD 的雜項:gzweb 時鐘(擋航向帶)、GLB 上傳框
'body.zy-fpv #clock,body.zy-fpv #clock-mouse,body.zy-fpv #clock-touch{display:none !important}' +
'body.zy-fpv #zy-glb{display:none !important}';
document.head.appendChild(css);
var p = el('
' +
'
🛩️ 飛行儀表 連線中…▾
' +
'
' +
'
' +
'
' +
'
' +
'
地速 GS-- m/s
' +
'
爬升 VS-- m/s
' +
'
高度 ALT-- m
' +
'
航向 HDG-- °
' +
'
' +
'
' +
'
🛡️ 前向測距:--
' +
'
🚀 來襲警示:淨空
' +
'
' +
'
GPS--
' +
'
電池--
' +
'
風--
' +
'
模式--
' +
'
氣壓計--
' +
'
座標--
' +
'
' +
'
IMU --
' +
'
');
document.body.appendChild(p);
document.getElementById('zy-hud-hd').addEventListener('click', function () {
p.classList.toggle('min');
document.getElementById('zy-hud-min').textContent = p.classList.contains('min') ? '▸' : '▾';
});
var osd = document.createElement('canvas'); osd.id = 'zy-osd';
document.body.appendChild(osd);
function fit() { osd.width = window.innerWidth; osd.height = window.innerHeight; }
fit(); window.addEventListener('resize', fit);
poll(); setInterval(poll, 300);
requestAnimationFrame(tick);
}
async function poll() {
try {
var r = await fetch('/api/telemetry/hud');
T = await r.json();
var st = document.getElementById('zy-hud-st');
st.textContent = T.connected ? (T.armed ? 'ARMED' : 'DISARMED') : '未連線';
st.style.color = T.connected ? (T.armed ? '#ff9d5c' : '#6fe08f') : '#e06f6f';
renderText();
} catch (e) {}
try {
var us = await (await fetch('/api/sensors/ultrasonic')).json();
var bar = document.getElementById('zy-us-bar'), txt = document.getElementById('zy-us-txt'),
icon = document.getElementById('zy-us-icon');
bar.classList.remove('zy-us-warn', 'zy-us-crit');
if (!us.enabled) { icon.textContent = '🛡️'; txt.textContent = '前向測距:關閉'; }
else if (us.avoiding) { icon.textContent = '🚨'; txt.textContent = '自動迴避中!'; bar.classList.add('zy-us-crit'); }
else if (us.front_m == null) { icon.textContent = '🛡️'; txt.textContent = '前向測距:淨空'; }
else if (us.critical) { icon.textContent = '🚨'; txt.textContent = '障礙 ' + us.front_m.toFixed(1) + 'm!'; bar.classList.add('zy-us-crit'); }
else if (us.warning) { icon.textContent = '⚠️'; txt.textContent = '前向測距:' + us.front_m.toFixed(1) + 'm'; bar.classList.add('zy-us-warn'); }
else { icon.textContent = '🛡️'; txt.textContent = '前向測距:' + us.front_m.toFixed(1) + 'm'; }
} catch (e) {}
try {
var ms = await (await fetch('/api/missiles/status')).json();
var sbar = document.getElementById('zy-sam-bar'), stxt = document.getElementById('zy-sam-txt');
var n = (ms.missiles || []).length;
sbar.classList.toggle('zy-sam-alert', n > 0);
stxt.textContent = n > 0 ? ('來襲警示:' + n + ' 枚追蹤中') : (ms.enabled ? '來襲警示:淨空' : '來襲警示:關閉');
} catch (e) {}
}
function fmt(v, d) { return v == null ? '--' : Number(v).toFixed(d == null ? 1 : d); }
function renderText() {
var g = function (id) { return document.getElementById(id); };
g('zy-gps').textContent = T.gps ? (T.gps.satellites + '顆 ' + (T.gps.fix || '')) : '--';
g('zy-bat').textContent = T.battery ? (fmt(T.battery.voltage_v, 1) + 'V ' + fmt(T.battery.pct, 0) + '%') : '--';
g('zy-wind').textContent = T.wind ? (fmt(T.wind.speed_mps, 0) + 'm/s ' + fmt(T.wind.direction_deg, 0) + '°') : '--';
g('zy-mode').textContent = (T.flight_mode || '--').replace('FlightMode.', '');
g('zy-baro').textContent = T.baro ? (fmt(T.baro.pressure_hpa, 0) + 'hPa') : '--';
g('zy-ll').textContent = T.position ? (fmt(T.position.lat, 5) + ', ' + fmt(T.position.lon, 5)) : '--';
var raw = 'IMU --';
if (T.imu) {
raw = 'ACC ' + T.imu.acc.join(' / ') + ' m/s²
' +
'GYRO ' + T.imu.gyro.join(' / ') + ' rad/s
' +
'MAG ' + T.imu.mag.join(' / ') + ' G · ' + fmt(T.imu.temp_c, 0) + '°C';
}
document.getElementById('zy-hud-raw').innerHTML = raw;
}
function lerp(a, b, k) { return a + (b - a) * k; }
function lerpAngle(a, b, k) {
var d = ((b - a + 540) % 360) - 180;
return a + d * k;
}
function tick(t) {
var dt = Math.min(0.1, (t - lastDraw) / 1000 || 0.016); lastDraw = t;
var k = 1 - Math.exp(-8 * dt);
var at = T.attitude || {};
D.roll = lerp(D.roll, at.roll_deg || 0, k);
D.pitch = lerp(D.pitch, at.pitch_deg || 0, k);
D.hdg = lerpAngle(D.hdg, T.heading_deg != null ? T.heading_deg : (at.yaw_deg || 0), k);
var vel = T.velocity || {};
D.gs = lerp(D.gs, vel.gs_mps || 0, k);
D.vs = lerp(D.vs, vel.vs_mps || 0, k);
D.alt = lerp(D.alt, T.position ? T.position.rel_alt_m : 0, k);
var g = function (id) { return document.getElementById(id); };
g('zy-gs').textContent = D.gs.toFixed(1);
g('zy-vs').textContent = (D.vs >= 0 ? '+' : '') + D.vs.toFixed(1);
g('zy-alt').textContent = D.alt.toFixed(1);
g('zy-hdg').textContent = ((D.hdg % 360 + 360) % 360).toFixed(0).padStart(3, '0');
var fpv = window.zyGetView && window.zyGetView() === 'fpv';
document.body.classList.toggle('zy-fpv', !!fpv);
if (fpv) drawOSD(); else drawADI();
requestAnimationFrame(tick);
}
// ===== FPV 眼鏡 OSD(Betaflight 風格:綠白描邊字、地平線、航向帶、回家箭頭)=====
var armedSince = null;
function osdText(c, s, x, y, size, align) {
c.font = '700 ' + (size || 15) + 'px ui-monospace,Consolas,monospace';
c.textAlign = align || 'left';
c.lineWidth = 3; c.strokeStyle = 'rgba(0,0,0,.85)'; c.strokeText(s, x, y);
c.fillStyle = '#d9f7c9'; c.fillText(s, x, y);
}
function drawOSD() {
var cv = document.getElementById('zy-osd'); if (!cv) return;
var c = cv.getContext('2d'), W = cv.width, H = cv.height, cx = W / 2, cy = H / 2;
c.clearRect(0, 0, W, H);
c.strokeStyle = '#d9f7c9'; c.fillStyle = '#d9f7c9';
// 中央準星
c.save(); c.lineWidth = 2.5; c.shadowColor = 'rgba(0,0,0,.9)'; c.shadowBlur = 2;
c.beginPath();
c.moveTo(cx - 14, cy); c.lineTo(cx - 5, cy);
c.moveTo(cx + 5, cy); c.lineTo(cx + 14, cy);
c.moveTo(cx, cy - 5); c.lineTo(cx, cy + 5);
c.stroke();
// 動態地平線(跟滾轉/俯仰;FPV 眼鏡的姿態參考線)
c.save();
c.translate(cx, cy);
c.rotate(-D.roll * Math.PI / 180);
var off = D.pitch * 4;
c.lineWidth = 2;
c.beginPath();
c.moveTo(-W * 0.22, off); c.lineTo(-40, off);
c.moveTo(40, off); c.lineTo(W * 0.22, off);
c.stroke();
c.beginPath(); // 端點小垂尾
c.moveTo(-W * 0.22, off); c.lineTo(-W * 0.22, off + 8);
c.moveTo(W * 0.22, off); c.lineTo(W * 0.22, off + 8);
c.stroke();
c.restore();
c.restore();
// 上方航向帶
var hdg = ((D.hdg % 360) + 360) % 360;
osdText(c, hdg.toFixed(0).padStart(3, '0') + '°', cx, 34, 18, 'center');
var names = { 0: 'N', 45: 'NE', 90: 'E', 135: 'SE', 180: 'S', 225: 'SW', 270: 'W', 315: 'NW' };
c.save(); c.beginPath(); c.rect(cx - 180, 40, 360, 26); c.clip();
for (var a = -60; a <= 60; a += 5) {
var deg = ((hdg + a) % 360 + 360) % 360, x = cx + a * 3;
if (deg % 45 === 0) osdText(c, names[deg], x, 60, 13, 'center');
else if (deg % 15 === 0) { c.fillRect(x - 1, 44, 2, 7); }
}
c.restore();
// 左側:地速 / 右側:高度+爬升
osdText(c, D.gs.toFixed(1), cx - 250, cy + 5, 22, 'right');
osdText(c, 'GS m/s', cx - 250, cy + 24, 11, 'right');
osdText(c, D.alt.toFixed(1), cx + 250, cy + 5, 22, 'left');
osdText(c, 'ALT m ' + (D.vs >= 0 ? '▲' : '▼') + Math.abs(D.vs).toFixed(1), cx + 250, cy + 24, 11, 'left');
// 回家方向箭頭+距離(場景原點=Home)
var iris = window.scene && window.scene.scene && window.scene.scene.getObjectByName('iris');
if (iris) {
var hd = Math.hypot(iris.position.x, iris.position.y);
var bearing = Math.atan2(-iris.position.x, -iris.position.y) * 180 / Math.PI; // 相對北
var rel = (bearing - hdg) * Math.PI / 180;
c.save(); c.translate(cx, 92); c.rotate(rel);
c.lineWidth = 2.5; c.shadowColor = 'rgba(0,0,0,.9)'; c.shadowBlur = 2;
c.beginPath(); c.moveTo(0, -10); c.lineTo(-6, 6); c.lineTo(0, 2); c.lineTo(6, 6); c.closePath(); c.fill();
c.restore();
osdText(c, 'HOME ' + hd.toFixed(0) + 'm', cx, 122, 12, 'center');
}
// 底部狀態列:電壓 | 模式/ARM | 計時 | GPS | 風
if (T.armed && !armedSince) armedSince = Date.now();
if (!T.armed) armedSince = null;
var tsec = armedSince ? Math.floor((Date.now() - armedSince) / 1000) : 0;
var timer = String(Math.floor(tsec / 60)).padStart(2, '0') + ':' + String(tsec % 60).padStart(2, '0');
var By = H - 26;
if (T.battery) osdText(c, T.battery.voltage_v.toFixed(1) + 'V ' + Math.round(T.battery.pct) + '%', 24, By, 16);
osdText(c, (T.flight_mode || '--').replace('FlightMode.', '') + (T.armed ? ' ARMED' : ''), cx - 120, By, 14, 'center');
osdText(c, '⏱' + timer, cx + 40, By, 14, 'center');
if (T.gps) osdText(c, '🛰' + T.gps.satellites, cx + 140, By, 14, 'center');
if (T.wind && T.wind.speed_mps > 0)
osdText(c, '💨' + T.wind.speed_mps.toFixed(0) + 'm/s', cx + 230, By, 14, 'center');
if (T.position) osdText(c, T.position.lat.toFixed(5) + ' ' + T.position.lon.toFixed(5), W - 24, By, 11, 'right');
}
function drawADI() {
var cv = document.getElementById('zy-adi'); if (!cv) return;
var c = cv.getContext('2d'), W = cv.width, H = cv.height, R = W / 2 - 2;
var px = 2.2; // 每度俯仰的像素
c.clearRect(0, 0, W, H);
c.save();
c.beginPath(); c.arc(W / 2, H / 2, R, 0, 7); c.clip();
c.translate(W / 2, H / 2);
c.rotate(-D.roll * Math.PI / 180);
var off = D.pitch * px;
c.fillStyle = '#2e6cb5'; c.fillRect(-W, -H * 2 + off, W * 2, H * 2); // 天
c.fillStyle = '#7a5230'; c.fillRect(-W, off, W * 2, H * 2); // 地
c.strokeStyle = '#fff'; c.lineWidth = 1.6;
c.beginPath(); c.moveTo(-W, off); c.lineTo(W, off); c.stroke(); // 地平線
c.lineWidth = 1; c.fillStyle = '#e8edf5'; c.font = '8px monospace'; c.textAlign = 'center';
[-20, -10, 10, 20].forEach(function (d) { // 俯仰梯
var y = off - d * px, w = Math.abs(d) === 10 ? 16 : 24;
c.strokeStyle = 'rgba(255,255,255,.85)';
c.beginPath(); c.moveTo(-w, y); c.lineTo(w, y); c.stroke();
c.fillText(Math.abs(d), w + 9, y + 3);
});
c.restore();
// 機徽(固定)
c.strokeStyle = '#ffd34d'; c.lineWidth = 2.4;
c.beginPath();
c.moveTo(W / 2 - 26, H / 2); c.lineTo(W / 2 - 8, H / 2);
c.moveTo(W / 2 + 8, H / 2); c.lineTo(W / 2 + 26, H / 2);
c.stroke();
c.fillStyle = '#ffd34d'; c.beginPath(); c.arc(W / 2, H / 2, 2.4, 0, 7); c.fill();
// 滾轉指標(頂端三角)
c.save(); c.translate(W / 2, H / 2); c.rotate(-D.roll * Math.PI / 180);
c.fillStyle = '#ffd34d';
c.beginPath(); c.moveTo(0, -R + 3); c.lineTo(-4, -R + 11); c.lineTo(4, -R + 11); c.fill();
c.restore();
// 外框
c.strokeStyle = '#3a4a70'; c.lineWidth = 2;
c.beginPath(); c.arc(W / 2, H / 2, R, 0, 7); c.stroke();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', build);
else build();
})();