884 lines
34 KiB
JavaScript
884 lines
34 KiB
JavaScript
// 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] 未找到合适锚点,已挂到 <body>');
|
||
}
|
||
}
|
||
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 = `
|
||
<div style="margin-bottom:8px;font-weight:bold;color:#61dafb;font-size:12px;border-bottom:1px solid #2a3446;padding-bottom:4px;">IMU 实时数据</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:8px;">
|
||
<div><div style="color:#9fb0c3;font-size:9px;">翻滚角 (Roll)</div>
|
||
<div id="panel-roll" style="color:#ff6b6b;font-size:14px;font-weight:bold;">0.0°</div></div>
|
||
<div><div style="color:#9fb0c3;font-size:9px;">俯仰角 (Pitch)</div>
|
||
<div id="panel-pitch" style="color:#4ecdc4;font-size:14px;font-weight:bold;">0.0°</div></div>
|
||
</div>
|
||
<div style="margin-bottom:8px;">
|
||
<div style="color:#9fb0c3;font-size:9px;">偏航角 (Yaw)</div>
|
||
<div id="panel-yaw" style="color:#45b7d1;font-size:14px;font-weight:bold;">0.0°</div>
|
||
</div>
|
||
<div style="border-top:1px solid #2a3446;padding-top:6px;margin-top:6px;">
|
||
<div style="color:#9fb0c3;font-size:9px;margin-bottom:4px;">角速度 (°/s)</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;">
|
||
<div><span style="color:#ff9999;font-size:9px;">gX:</span><span id="panel-gx" style="color:#ff9999;font-size:10px;">0.0</span></div>
|
||
<div><span style="color:#99ff99;font-size:9px;">gY:</span><span id="panel-gy" style="color:#99ff99;font-size:10px;">0.0</span></div>
|
||
<div><span style="color:#9999ff;font-size:9px;">gZ:</span><span id="panel-gz" style="color:#9999ff;font-size:10px;">0.0</span></div>
|
||
</div>
|
||
</div>
|
||
<div style="border-top:1px solid #2a3446;padding-top:6px;margin-top:6px;">
|
||
<div style="color:#9fb0c3;font-size:9px;margin-bottom:4px;">加速度 (m/s²)</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;">
|
||
<div><span style="color:#ff9999;font-size:9px;">aX:</span><span id="panel-ax" style="color:#ff9999;font-size:10px;">0.0</span></div>
|
||
<div><span style="color:#99ff99;font-size:9px;">aY:</span><span id="panel-ay" style="color:#99ff99;font-size:10px;">0.0</span></div>
|
||
<div><span style="color:#9999ff;font-size:9px;">aZ:</span><span id="panel-az" style="color:#9999ff;font-size:10px;">0.0</span></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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();
|
||
})();
|