// 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 = `