// 飛行儀表 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(); })();