// static/main.js // ================= 摄像头 + ASR ================= (() => { const $camStatus = document.getElementById('camStatus'); const $asrStatus = document.getElementById('asrStatus'); const $partial = document.getElementById('partial'); const $finalList = document.getElementById('finalList'); const $btnClear = document.getElementById('btnClear'); const $btnRe = document.getElementById('btnReconnect'); const $fps = document.getElementById('fps'); const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); // === 获取/创建聊天容器(关键补丁) === let chatContainer = document.getElementById('chatContainer'); function ensureChatContainer() { // 已缓存且仍在文档中 if (chatContainer && document.body.contains(chatContainer)) return chatContainer; // 重新获取,防热更新或 DOM 移动 chatContainer = document.getElementById('chatContainer'); if (!chatContainer) { chatContainer = document.createElement('div'); chatContainer.id = 'chatContainer'; // 优先挂到 finalList 的父容器;否则挂到 partial 的父容器;再否则挂到 body 兜底 if ($finalList && $finalList.parentElement) { // 隐藏原来的 finalList $finalList.style.display = 'none'; // 将聊天容器挂载到 finals div 内 $finalList.parentElement.appendChild(chatContainer); console.log('[chat] 创建并挂载 #chatContainer 到 finalList 区域'); } else if ($partial && $partial.parentElement) { $partial.parentElement.appendChild(chatContainer); console.log('[chat] 创建并挂载 #chatContainer 到 partial 区域'); } else { document.body.appendChild(chatContainer); console.warn('[chat] 未找到合适锚点,已挂到 '); } } return chatContainer; } // === 注入聊天样式(左右两侧气泡 + 时间戳,增加权重)=== (function injectChatStyles() { if (document.getElementById('chat-style-injected')) return; const s = document.createElement('style'); s.id = 'chat-style-injected'; s.textContent = ` #chatContainer{ position: relative !important; overflow-y: auto !important; flex: 1 !important; /* 改为使用 flex: 1 占满剩余空间 */ min-height: 0 !important; /* 确保 flex 子元素能正确收缩 */ padding: 12px 12px 4px !important; background: #0b1020 !important; border: 1px solid #1d2438 !important; border-radius: 10px !important; margin-top: 12px !important; } /* 自定义滚动条样式 */ #chatContainer::-webkit-scrollbar { width: 8px !important; } #chatContainer::-webkit-scrollbar-track { background: #0d1420 !important; border-radius: 4px !important; } #chatContainer::-webkit-scrollbar-thumb { background: #2a3446 !important; border-radius: 4px !important; transition: background 0.2s !important; } #chatContainer::-webkit-scrollbar-thumb:hover { background: #3a4556 !important; } /* Firefox 滚动条 */ #chatContainer { scrollbar-width: thin !important; scrollbar-color: #2a3446 #0d1420 !important; } .timestamp{ text-align:center !important; font-size:12px !important; color:#8a93a5 !important; margin:10px 0 !important; user-select:none !important; } .message{ display:flex !important; gap:8px !important; margin:6px 0 !important; align-items:flex-end !important; } .message.ai{ justify-content:flex-start !important; } .message.user{ justify-content:flex-end !important; } .avatar{ width:28px !important; height:28px !important; border-radius:50% !important; background:#232a3d !important; flex:0 0 28px !important; display:flex !important; align-items:center !important; justify-content:center !important; color:#9fb0c3 !important; font-size:12px !important; user-select:none !important; border:1px solid #29314a !important; } .message.user .avatar{ display:none !important; } .bubble{ max-width: 72% !important; padding:10px 12px !important; line-height:1.45 !important; border-radius:14px !important; word-break:break-word !important; white-space:pre-wrap !important; border:1px solid transparent !important; box-shadow:0 2px 8px rgba(0,0,0,0.15) !important; font-size:14px !important; } .message.ai .bubble{ background:#111a2e !important; color:#e6edf3 !important; border-color:#1e2740 !important; border-top-left-radius:6px !important; } .message.user .bubble{ background:#2a6df4 !important; color:#fff !important; border-color:#2a6df4 !important; border-top-right-radius:6px !important; } `; document.head.appendChild(s); })(); // 聊天消息管理 let lastTimestamp = 0; const TIMESTAMP_INTERVAL = 5 * 60 * 1000; // 5分钟 function shouldShowTimestamp() { const now = Date.now(); if (now - lastTimestamp > TIMESTAMP_INTERVAL) { lastTimestamp = now; return true; } return false; } function formatTime(timestamp = Date.now()) { const date = new Date(timestamp); const hours = date.getHours().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0'); return `${hours}:${minutes}`; } function addTimestamp() { const container = ensureChatContainer(); const timestampDiv = document.createElement('div'); timestampDiv.className = 'timestamp'; timestampDiv.textContent = formatTime(); container.appendChild(timestampDiv); } function addMessage(text, isUser = false) { // 时间戳 if (shouldShowTimestamp()) addTimestamp(); const container = ensureChatContainer(); // 行容器 const messageDiv = document.createElement('div'); messageDiv.className = `message ${isUser ? 'user' : 'ai'}`; // 左侧头像(AI) const avatar = document.createElement('div'); avatar.className = 'avatar'; avatar.textContent = isUser ? '' : 'AI'; // 气泡 const bubbleDiv = document.createElement('div'); bubbleDiv.className = 'bubble'; bubbleDiv.textContent = text; if (isUser) { // 右侧:气泡在右 messageDiv.appendChild(bubbleDiv); } else { // 左侧:头像 + 气泡 messageDiv.appendChild(avatar); messageDiv.appendChild(bubbleDiv); } container.appendChild(messageDiv); // 滚动到底部 container.scrollTop = container.scrollHeight; } // Day 20: 更新 badge 样式,支持 connecting 状态动画 function setBadge(el, status, text) { el.textContent = text; // status: 'ok', 'err', 'connecting' if (status === true) status = 'ok'; if (status === false) status = 'err'; el.className = 'badge ' + (status || ''); } function navLabelAndText(raw) { // 去掉前缀 “[导航] ” const t = raw.startsWith('[导航]') ? raw.substring(4).trim() : raw; // 粗略判断:含“斑马线/绿灯/红灯/黄灯/过马路”归为斑马线导航,否则盲道导航 const crossHints = ['斑马线', '绿灯', '红灯', '黄灯', '过马路']; const isCross = crossHints.some(k => t.includes(k)); const label = isCross ? '【斑马线导航】' : '【盲道导航】'; return { label, text: `${label} ${t}` }; } // 改进的 fitCanvas: 支持移动端尺寸计算 function fitCanvas() { const rect = canvas.getBoundingClientRect(); // 使用容器实际宽高,添加最小值保护 const w = Math.max(320, Math.floor(rect.width) || 320); let h = Math.floor(rect.height) || 0; // 如果容器高度太小或为0,使用4:3比例回退 if (h < 100) { h = Math.max(240, Math.floor(w * 3 / 4)); } if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; console.log('[Canvas] 尺寸调整:', w, 'x', h); } } window.addEventListener('resize', fitCanvas); // 延迟初始化,确保布局完成 setTimeout(fitCanvas, 100); fitCanvas(); let wsCam, wsUI, frames = 0, fpsTimer = 0; function drawBlob(buf) { const blob = new Blob([buf], { type: 'image/jpeg' }); if ('createImageBitmap' in window) { createImageBitmap(blob).then(bmp => { fitCanvas(); ctx.drawImage(bmp, 0, 0, canvas.width, canvas.height); }).catch(() => { }); } else { const img = new Image(); img.onload = () => { fitCanvas(); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); URL.revokeObjectURL(img.src); }; img.src = URL.createObjectURL(blob); } frames++; const now = performance.now(); if (!fpsTimer) fpsTimer = now; if (now - fpsTimer >= 1000) { $fps.textContent = 'FPS: ' + frames; frames = 0; fpsTimer = now; } } function connectCamera() { try { if (wsCam) wsCam.close(); } catch (e) { } const proto = location.protocol === 'https:' ? 'wss' : 'ws'; wsCam = new WebSocket(`${proto}://${location.host}/ws/viewer`); setBadge($camStatus, 'connecting', '📷 连接中...'); wsCam.binaryType = 'arraybuffer'; wsCam.onopen = () => setBadge($camStatus, 'ok', '📷 已连接'); wsCam.onclose = () => setBadge($camStatus, 'err', '📷 已断开'); wsCam.onerror = () => setBadge($camStatus, 'err', '📷 连接错误'); wsCam.onmessage = (ev) => drawBlob(ev.data); } function connectASR() { try { if (wsUI) wsUI.close(); } catch (e) { } const proto = location.protocol === 'https:' ? 'wss' : 'ws'; wsUI = new WebSocket(`${proto}://${location.host}/ws_ui`); setBadge($asrStatus, 'connecting', '🎤 连接中...'); wsUI.onopen = () => setBadge($asrStatus, 'ok', '🎤 已连接'); wsUI.onclose = () => setBadge($asrStatus, 'err', '🎤 已断开'); wsUI.onerror = () => setBadge($asrStatus, 'err', '🎤 连接错误'); wsUI.onmessage = (ev) => { const s = ev.data || ''; if (s.startsWith('INIT:')) { try { const data = JSON.parse(s.slice(5)); $partial.textContent = data.partial || '(等待音频…)'; // 初始化时加载历史消息(识别 [AI] 与 [导航]) if (data.finals && data.finals.length > 0) { data.finals.forEach(text => { if (text.startsWith('[AI]')) { addMessage(text.substring(4).trim(), false); } else if (text.startsWith('[导航]')) { const { text: show } = navLabelAndText(text); addMessage(show, false); } else { addMessage(text, true); } }); } } catch (e) { } return; } if (s.startsWith('PARTIAL:')) { $partial.textContent = s.slice(8); return; } if (s.startsWith('FINAL:')) { const text = s.slice(6); if (text.startsWith('[AI]')) { addMessage(text.substring(4).trim(), false); } else if (text.startsWith('[导航]')) { const { text: show } = navLabelAndText(text); addMessage(show, false); // 左侧 AI } else { addMessage(text, true); // 其它仍按右侧 } $partial.textContent = '(等待音频…)'; return; } } } $btnClear.onclick = () => { const container = ensureChatContainer(); // 清空聊天记录 const messages = container.querySelectorAll('.message, .timestamp'); messages.forEach(msg => msg.remove()); lastTimestamp = 0; // 重置时间戳计数 }; $btnRe.onclick = () => { connectCamera(); connectASR(); }; connectCamera(); connectASR(); })(); // ================= IMU 3D(无虚线框、无滚动条、上下对齐、自适应) ================= import * as THREE from 'three'; import { GLTFLoader } from 'https://unpkg.com/three@0.155.0/examples/jsm/loaders/GLTFLoader.js'; // Day 20: IMU 浮窗折叠功能 - 修复:兼容模块延迟加载 // Day 23: 移动端优化 - 默认折叠 function initImuToggle() { const imuFloat = document.getElementById('imuFloat'); const imuToggle = document.getElementById('imuToggle'); console.log('[IMU] 初始化折叠功能, imuFloat:', !!imuFloat, 'imuToggle:', !!imuToggle); if (imuFloat && imuToggle) { // 检测移动端 - 默认折叠 const isMobile = window.innerWidth < 1100; if (isMobile) { imuFloat.classList.add('collapsed'); imuFloat.classList.remove('expanded'); imuToggle.textContent = '+'; console.log('[IMU] 移动端检测,默认折叠'); } else { imuFloat.classList.add('expanded'); } imuToggle.onclick = function (e) { e.preventDefault(); e.stopPropagation(); const isCollapsed = imuFloat.classList.toggle('collapsed'); imuFloat.classList.toggle('expanded', !isCollapsed); this.textContent = isCollapsed ? '+' : '−'; console.log('[IMU] 折叠状态:', isCollapsed); }; console.log('[IMU] 折叠按钮事件已绑定'); } } // 确保 DOM 加载后执行(兼容模块延迟加载) if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initImuToggle); } else { // DOM 已加载完成,直接执行 initImuToggle(); } (() => { const container = document.getElementById('imu_view'); // 左侧3D容器 const hud = document.getElementById('imu_hud'); // 右侧IMU容器 // 左右窗口统一半透明底色 if (container) container.style.background = 'rgba(0,0,0,0.2)'; if (hud) { // 关键:右侧容器作为定位参考,同时禁止滚动、清理边框 Object.assign(hud.style, { position: 'relative', overflow: 'hidden', border: 'none', outline: 'none', background: 'rgba(0,0,0,0.2)', // 右侧也给统一底色(整块),干净无额外面板底色 borderRadius: '10px' }); } // —— 彻底去掉“虚线框”和一切边框/阴影(含可能的外层壳)—— (function killFraming() { const s = document.createElement('style'); s.textContent = ` #imu_view, #imu_hud, #data-panel, #imu_dock, .imu-card, .imu-wrap, .panel, .card, .window { border: none !important; outline: none !important; box-shadow: none !important; background-image: none !important; } /* 兜底:清除任何内联 dashed/ dotted */ [style*="dashed"], [style*="dotted"] { border-style: none !important; outline: none !important; } `; document.head.appendChild(s); // 同时清理父级(最多向上两层)里的边框与滚动条,避免外层虚线框和滚动条 [container, hud].forEach(el => { let p = el ? el.parentElement : null; for (let i = 0; i < 2 && p; i++, p = p.parentElement) { p.style.border = 'none'; p.style.outline = 'none'; p.style.boxShadow = 'none'; p.style.overflow = 'hidden'; p.style.backgroundImage = 'none'; } }); })(); // 右侧:不再额外创建 dock 背板(直接用 hud 当整块背景) // 数据面板只负责显示文字,不再自带背景与边框 // three.js 渲染器 const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 1000); // 画质相关 renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.0; renderer.setClearColor(0x000000, 0); // 透明背景 // ——— 核心:左右窗口“上下齐+自适应等比” ——— let syncRaf = 0; function syncHeights() { if (!container || !hud) return; const w = container.clientWidth || 300; // 恢复合理高度设置 const padding = 20; const contentH = (document.getElementById('data-panel')?.offsetHeight || 0) + padding; let targetH = Math.max(180, contentH); // 最小高度 180px hud.style.height = `${targetH}px`; hud.style.maxHeight = 'none'; hud.style.overflow = 'hidden'; container.style.height = `${targetH}px`; renderer.setSize(w, targetH); camera.aspect = w / targetH; camera.updateProjectionMatrix(); } function requestSync() { cancelAnimationFrame(syncRaf); syncRaf = requestAnimationFrame(syncHeights); } // 初次与窗口变化时,同步左右高度 requestSync(); window.addEventListener('resize', requestSync); // 数据变化时也同步(放在 updateDataPanel 内) function updateDataPanel(roll, pitch, yaw, gx, gy, gz, ax, ay, az) { document.getElementById('panel-roll').textContent = roll.toFixed(1) + '°'; document.getElementById('panel-pitch').textContent = pitch.toFixed(1) + '°'; document.getElementById('panel-yaw').textContent = yaw.toFixed(1) + '°'; document.getElementById('panel-gx').textContent = gx.toFixed(1); document.getElementById('panel-gy').textContent = gy.toFixed(1); document.getElementById('panel-gz').textContent = gz.toFixed(1); document.getElementById('panel-ax').textContent = ax.toFixed(2); document.getElementById('panel-ay').textContent = ay.toFixed(2); document.getElementById('panel-az').textContent = az.toFixed(2); requestSync(); // 数据刷新后同步高度 } container.appendChild(renderer.domElement); // ========== 场景 ========== const group = new THREE.Group(); scene.add(group); const axesHelper = new THREE.AxesHelper(4); scene.add(axesHelper); function createAxisLabel(text, position, color) { const c = document.createElement('canvas'); const ctx = c.getContext('2d'); c.width = 128; c.height = 64; ctx.fillStyle = color; ctx.font = 'Bold 24px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(text, 64, 32); const tex = new THREE.CanvasTexture(c); const mat = new THREE.SpriteMaterial({ map: tex }); const spr = new THREE.Sprite(mat); spr.position.copy(position); spr.scale.set(0.8, 0.4, 1); return spr; } scene.add(createAxisLabel('X', new THREE.Vector3(4.5, 0, 0), '#ff0000')); scene.add(createAxisLabel('Y', new THREE.Vector3(0, 4.5, 0), '#00ff00')); scene.add(createAxisLabel('Z', new THREE.Vector3(0, 0, 4.5), '#0000ff')); function createScale() { const g = new THREE.Group(); for (let i = 1; i <= 4; i++) { const geo = new THREE.SphereGeometry(0.05, 8, 6); const mk = (c) => new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: c })); const mx = mk(0xff4444); mx.position.set(i, 0, 0); g.add(mx); const my = mk(0x44ff44); my.position.set(0, i, 0); g.add(my); const mz = mk(0x4444ff); mz.position.set(0, 0, i); g.add(mz); } return g; } scene.add(createScale()); function createDirectionLabels() { [ { t: '前', p: new THREE.Vector3(0, 0, 5), c: '#00ffff' }, { t: '后', p: new THREE.Vector3(0, 0, -5), c: '#00ffff' }, { t: '左', p: new THREE.Vector3(-5, 0, 0), c: '#ffff00' }, { t: '右', p: new THREE.Vector3(5, 0, 0), c: '#ffff00' }, { t: '上', p: new THREE.Vector3(0, 5, 0), c: '#ff00ff' }, { t: '下', p: new THREE.Vector3(0, -5, 0), c: '#ff00ff' }, ].forEach(d => scene.add(createAxisLabel(d.t, d.p, d.c))); } createDirectionLabels(); camera.position.set(4, 4, 6); camera.lookAt(0, 0, 0); // ========== 右侧 IMU 数据展示 ========== function createDataPanel() { const panel = document.createElement('div'); panel.id = 'data-panel'; panel.style.cssText = ` position: relative; background: transparent; border: none; padding: 10px; width: 100%; color: #e6edf3; font-family: 'Consolas','Monaco',monospace; font-size: 11px; box-shadow: none; overflow: hidden; `; panel.innerHTML = `
IMU 实时数据
翻滚角 (Roll)
0.0°
俯仰角 (Pitch)
0.0°
偏航角 (Yaw)
0.0°
角速度 (°/s)
gX:0.0
gY:0.0
gZ:0.0
加速度 (m/s²)
aX:0.0
aY:0.0
aZ:0.0
`; hud.appendChild(panel); return panel; } const dataPanel = createDataPanel(); function updateDataPanel(roll, pitch, yaw, gx, gy, gz, ax, ay, az) { document.getElementById('panel-roll').textContent = roll.toFixed(1) + '°'; document.getElementById('panel-pitch').textContent = pitch.toFixed(1) + '°'; document.getElementById('panel-yaw').textContent = yaw.toFixed(1) + '°'; document.getElementById('panel-gx').textContent = gx.toFixed(1); document.getElementById('panel-gy').textContent = gy.toFixed(1); document.getElementById('panel-gz').textContent = gz.toFixed(1); document.getElementById('panel-ax').textContent = ax.toFixed(2); document.getElementById('panel-ay').textContent = ay.toFixed(2); document.getElementById('panel-az').textContent = az.toFixed(2); } // ========== 灯光 ========== const ambientLight = new THREE.AmbientLight(0x404080, 0.3); scene.add(ambientLight); const mainLight = new THREE.DirectionalLight(0x00aaff, 1.2); mainLight.position.set(5, 8, 5); mainLight.castShadow = true; mainLight.shadow.mapSize.width = 2048; mainLight.shadow.mapSize.height = 2048; mainLight.shadow.camera.near = 0.5; mainLight.shadow.camera.far = 50; scene.add(mainLight); const fillLight = new THREE.DirectionalLight(0xff6633, 0.8); fillLight.position.set(-5, 3, -3); scene.add(fillLight); const rimLight = new THREE.DirectionalLight(0x66ffff, 0.6); rimLight.position.set(0, -5, 8); scene.add(rimLight); const pointLight1 = new THREE.PointLight(0x00ff88, 0.5, 20); pointLight1.position.set(3, 2, 4); scene.add(pointLight1); const pointLight2 = new THREE.PointLight(0xff3388, 0.4, 15); pointLight2.position.set(-3, -2, 2); scene.add(pointLight2); const spotLight = new THREE.SpotLight(0xffffff, 1.0, 30, Math.PI / 6, 0.3, 1); spotLight.position.set(0, 10, 8); spotLight.target.position.set(0, 0, 0); spotLight.castShadow = true; scene.add(spotLight); scene.add(spotLight.target); let lightTime = 0; function updateLighting() { lightTime += 0.01; mainLight.intensity = 1.2 + Math.sin(lightTime * 2) * 0.2; pointLight1.intensity = 0.5 + Math.sin(lightTime * 3) * 0.2; pointLight2.intensity = 0.4 + Math.cos(lightTime * 2.5) * 0.2; const hue = (Math.sin(lightTime * 0.5) + 1) * 0.3; rimLight.color.setHSL(0.5 + hue, 1.0, 0.7); } // ========== 模型 ========== let glassModel = null; const loader = new GLTFLoader(); loader.load( '/static/models/aiglass.glb', (gltf) => { glassModel = gltf.scene; glassModel.scale.set(2, 2, 2); glassModel.position.set(0, 0, 0); glassModel.traverse((child) => { if (child.isMesh) { child.castShadow = true; child.receiveShadow = true; if (child.material) { if (child.material.transparent || child.material.opacity < 1) { child.material.envMapIntensity = 1.5; child.material.roughness = 0.1; child.material.metalness = 0.8; } } } }); group.add(glassModel); }, undefined, (error) => { console.error('GLB加载失败:', error); const fallbackCube = new THREE.Mesh( new THREE.BoxGeometry(2, 2, 2), new THREE.MeshStandardMaterial({ color: 0x00aaff, metalness: 0.7, roughness: 0.3, envMapIntensity: 1.0 }) ); fallbackCube.castShadow = true; fallbackCube.receiveShadow = true; group.add(fallbackCube); } ); // 渲染循环 (function animate() { requestAnimationFrame(animate); updateLighting(); renderer.render(scene, camera); })(); // ===== IMU 数学与数据通道(原逻辑保持) ===== // 安装补偿 const MOUNT_RX = 0, MOUNT_RY = -90, MOUNT_RZ = 0; const qMount = new THREE.Quaternion() .multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(MOUNT_RY))) .multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), THREE.MathUtils.degToRad(MOUNT_RZ))) .multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), THREE.MathUtils.degToRad(MOUNT_RX))); const FOLLOW = 0.85; const $ = id => document.getElementById(id); const updateSlider = (idBase, v) => { const sl = $(`${idBase}_sl`), tv = $(`${idBase}_val`); if (sl) { const min = +sl.min, max = +sl.max; sl.value = Math.max(min, Math.min(max, v)); } if (tv) tv.textContent = (typeof v === 'number' ? v.toFixed(2) : '-'); }; let MED_N = Number($('medn').value); $('medn').onchange = e => MED_N = Number(e.target.value); let STILL_W = Number($('still_w').value); $('still_w').onchange = e => STILL_W = Number(e.target.value); let ANG_EMA = Number($('ang_ema').value); $('ang_ema').onchange = e => ANG_EMA = Number(e.target.value); let GRAV_BETA = Number($('grav_beta').value); $('grav_beta').onchange = e => GRAV_BETA = Number(e.target.value); let YAW_DB = Number($('yaw_db').value); $('yaw_db').onchange = e => YAW_DB = Number(e.target.value); let YAW_LEAK = Number($('yaw_leak').value); $('yaw_leak').onchange = e => YAW_LEAK = Number(e.target.value); let autoRezero = true; $('auto_rezero').onchange = e => { autoRezero = e.target.checked; }; let autoBias = true; $('auto_bias').onchange = e => { autoBias = e.target.checked; }; let useProj = true; $('use_proj').onchange = e => { useProj = e.target.checked; }; let freezeStill = true; $('freeze_still').onchange = e => { freezeStill = e.target.checked; }; const mkMed = () => ({ buf: [], push(v) { this.buf.push(v); if (this.buf.length > MED_N) this.buf.shift(); const arr = [...this.buf].sort((a, b) => a - b); const m = arr[Math.floor(arr.length / 2)]; return { median: m, valid: this.buf.length === MED_N }; } }); const fx = mkMed(), fy = mkMed(), fz = mkMed(); const gx = mkMed(), gy = mkMed(), gz = mkMed(); const rad2deg = r => r * 180 / Math.PI; const wrap180 = a => { a %= 360; if (a >= 180) a -= 360; if (a < -180) a += 360; return a; }; let lastTS = 0; let yaw = 0; let ref = { roll: 0, pitch: 0, yaw: 0 }; let holdStart = 0, isStill = false; let gLP = { x: 0, y: 0, z: 0 }; const G = 9.807, A_TOL = 0.08 * G; let gOff = { x: 0, y: 0, z: 0 }; const BIAS_ALPHA = 0.002; let Rf = 0, Pf = 0, Yf = 0; document.getElementById('btn_zero').onclick = () => { ref = { roll: Rf, pitch: Pf, yaw: Yf }; }; document.getElementById('btn_reset').onclick = () => { ref = { roll: 0, pitch: 0, yaw: 0 }; yaw = 0; Rf = 0; Pf = 0; Yf = 0; }; document.getElementById('btn_bias_now').onclick = () => { gOff = { ...lastGy }; }; let lastGy = { x: 0, y: 0, z: 0 }; const imu_ws_state = document.getElementById('imu_ws_state'); function setImuBadge(ok, text) { imu_ws_state.textContent = text; imu_ws_state.className = 'badge ' + (ok ? 'ok' : 'err'); } const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws'); setImuBadge(false, 'connecting…'); ws.onopen = () => setImuBadge(true, 'connected'); ws.onclose = () => setImuBadge(false, 'disconnected'); ws.onerror = () => setImuBadge(false, 'error'); ws.onmessage = (ev) => { try { const d = JSON.parse(ev.data); const t = (typeof d.ts === 'number') ? d.ts : performance.now(); let dt = (!lastTS || (t - lastTS) <= 0 || (t - lastTS) > 300) ? 0.02 : (t - lastTS) / 1000; lastTS = t; let ax = Number(d?.accel?.x) || 0, ay = Number(d?.accel?.y) || 0, az = Number(d?.accel?.z) || 0; let wx = Number(d?.gyro?.x) || 0, wy = Number(d?.gyro?.y) || 0, wz = Number(d?.gyro?.z) || 0; const fxr = fx.push(ax), fyr = fy.push(ay), fzr = fz.push(az); const gxr = gx.push(wx), gyr = gy.push(wy), gzr = gz.push(wz); if (fxr.valid) { ax = fxr.median; ay = fyr.median; az = fzr.median; } if (gxr.valid) { wx = gxr.median; wy = gyr.median; wz = gzr.median; } lastGy = { x: wx, y: wy, z: wz }; gLP.x = GRAV_BETA * gLP.x + (1 - GRAV_BETA) * ax; gLP.y = GRAV_BETA * gLP.y + (1 - GRAV_BETA) * ay; gLP.z = GRAV_BETA * gLP.z + (1 - GRAV_BETA) * az; const gmag = Math.hypot(gLP.x, gLP.y, gLP.z) || 1; const gHat = { x: gLP.x / gmag, y: gLP.y / gmag, z: gLP.z / gmag }; const roll = rad2deg(Math.atan2(az, ay)); const pitch = rad2deg(Math.atan2(-ax, ay)); const aNorm = Math.hypot(ax, ay, az); const wNorm = Math.hypot(wx, wy, wz); const nearFlat = Math.abs(roll) < 2.0 && Math.abs(pitch) < 2.0; const stillCond = (Math.abs(aNorm - G) < A_TOL) && (wNorm < STILL_W); if (stillCond) { if (!holdStart) holdStart = t; if (!isStill && (t - holdStart) > 350) isStill = true; if (autoBias) { gOff.x = (1 - BIAS_ALPHA) * gOff.x + BIAS_ALPHA * wx; gOff.y = (1 - BIAS_ALPHA) * gOff.y + BIAS_ALPHA * wy; gOff.z = (1 - BIAS_ALPHA) * gOff.z + BIAS_ALPHA * wz; } } else { holdStart = 0; isStill = false; } let yawdot = useProj ? ((wx - gOff.x) * gHat.x + (wy - gOff.y) * gHat.y + (wz - gOff.z) * gHat.z) : (wy - gOff.y); if (Math.abs(yawdot) < YAW_DB) yawdot = 0; if (freezeStill && stillCond) yawdot = 0; yaw = wrap180(yaw + yawdot * dt); if (YAW_LEAK > 0 && nearFlat && stillCond && Math.abs(yaw) > 0) { const step = YAW_LEAK * dt * Math.sign(-yaw); if (Math.abs(yaw) <= Math.abs(step)) yaw = 0; else yaw += step; } const alpha = ANG_EMA; Rf = alpha * roll + (1 - alpha) * Rf; Pf = alpha * pitch + (1 - alpha) * Pf; Yf = alpha * yaw + (1 - alpha) * Yf; if (autoRezero && nearFlat && wNorm < STILL_W) { if (!holdStart) holdStart = t; if (!isStill && (t - holdStart) > 350) { ref = { roll: Rf, pitch: Pf, yaw: Yf }; isStill = true; } } const R = wrap180(Rf - ref.roll); const P = wrap180(Pf - ref.pitch); const Y = wrap180(Yf - ref.yaw); const qBody = new THREE.Quaternion() .multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(Y))) .multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), THREE.MathUtils.degToRad(P))) .multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), THREE.MathUtils.degToRad(R))); const q = qMount.clone().multiply(qBody); if (FOLLOW >= 0.999) group.setRotationFromQuaternion(q); else group.quaternion.slerp(q, FOLLOW); updateSlider('roll', R); updateSlider('pitch', P); updateSlider('yaw', Y); updateSlider('gx', wx); updateSlider('gy', wy); updateSlider('gz', wz); updateSlider('ax', ax); updateSlider('ay', ay); updateSlider('az', az); // 更新右侧数据 updateDataPanel(R, P, Y, wx, wy, wz, ax, ay, az); } catch (e) { } }; // 初次与窗口改变时,保持左右上下对齐 window.addEventListener('resize', resize); resize(); })();