Manager
View Site
Name
Type
React (.jsx)
HTML (.html)
Icon
Description
Code Editor
Revision History (0)
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>XIAO Wand Pointer Lab</title> <style> :root { --bg: #f0e8dc; --panel: #fffaf3; --line: #d9c7af; --ink: #1f1912; --muted: #655847; --accent: #0f6a5c; --accent-2: #a94f25; --accent-3: #355070; --gx: #d9480f; --gy: #0f766e; --gz: #4338ca; --ax: #6f9a37; --ay: #8b5cf6; --az: #d97706; --shadow: 0 10px 28px rgba(43, 34, 24, 0.08); } * { box-sizing: border-box; } html, body { margin: 0; height: 100%; } body { overflow: hidden; font-family: Georgia, "Iowan Old Style", serif; color: var(--ink); background: radial-gradient(circle at top left, rgba(15, 106, 92, 0.12), transparent 24%), radial-gradient(circle at top right, rgba(169, 79, 37, 0.12), transparent 22%), linear-gradient(180deg, #faf6ef, var(--bg)); } button, input, select { font: inherit; } .app { height: 100vh; display: grid; grid-template-rows: auto 1fr; gap: 6px; padding: 6px; } .bar, .panel, .tile { background: color-mix(in srgb, var(--panel) 94%, white); border: 1px solid var(--line); box-shadow: var(--shadow); } .bar { border-radius: 14px; padding: 6px 8px; display: grid; grid-template-columns: auto auto auto auto 1fr auto auto auto auto; gap: 6px; align-items: center; } .title { min-width: 0; display: grid; gap: 2px; padding: 0 8px 0 2px; } .title strong { font-size: 1rem; letter-spacing: -0.02em; } .title span { color: var(--muted); font-size: 0.8rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .toolbar-group { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; } .chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 10px; border-radius: 999px; border: 1px solid var(--line); background: rgba(255,255,255,0.72); color: var(--muted); font-size: 0.8rem; white-space: nowrap; } button { border: 1px solid transparent; border-radius: 999px; padding: 8px 12px; cursor: pointer; } button:disabled { opacity: 0.55; cursor: not-allowed; } .primary { background: var(--accent); color: #fff; } .secondary { background: #ebe1d2; color: var(--ink); } .ghost { background: transparent; border-color: var(--line); color: var(--ink); } .control { display: grid; gap: 4px; color: var(--muted); font-size: 0.78rem; } .control select, .control input { min-width: 0; padding: 7px 10px; border-radius: 10px; border: 1px solid var(--line); background: #fff; color: var(--ink); } .workspace { min-height: 0; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 320px; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); gap: 6px; } .panel { min-height: 0; border-radius: 16px; padding: 6px; display: grid; gap: 6px; overflow: hidden; } .panel-header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; min-width: 0; } .panel-header h2 { margin: 0; font-size: 0.95rem; letter-spacing: -0.02em; } .panel-header span { color: var(--muted); font-size: 0.75rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .signal-grid { min-height: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 6px; } .tile { min-height: 0; border-radius: 12px; padding: 6px; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; gap: 6px; overflow: hidden; } .tile-head { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; font-size: 0.8rem; min-width: 0; } .tile-head strong { font-size: 0.84rem; } .tile-head span { color: var(--muted); font-size: 0.72rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } canvas { width: 100%; height: 100%; display: block; border-radius: 10px; border: 1px solid var(--line); background: linear-gradient(180deg, rgba(255,255,255,0.9), rgba(245,238,228,0.92)); touch-action: none; } .value-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; } .value-pill { display: grid; gap: 2px; padding: 5px 6px; border-radius: 10px; border: 1px solid var(--line); background: rgba(255,255,255,0.74); min-width: 0; } .value-pill span { color: var(--muted); font-size: 0.68rem; } .value-pill strong { font-size: 0.78rem; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .side-panel { grid-column: 3; grid-row: 1 / 3; display: grid; grid-template-rows: auto minmax(0, 0.82fr) auto minmax(0, 1fr) 108px; gap: 6px; } .pointer-controls { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; } .bt-actions { display: flex; gap: 6px; flex-wrap: wrap; } .button-row { display: flex; gap: 6px; flex-wrap: wrap; } .pointer-shell { min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 6px; } .pointer-readout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; } .log-box { height: 100%; min-height: 0; overflow: auto; padding: 8px; border: 1px solid var(--line); border-radius: 10px; background: rgba(255,255,255,0.74); color: var(--muted); font-size: 0.74rem; line-height: 1.35; white-space: pre-wrap; } .raw-panel { grid-column: 1 / 3; grid-row: 1; } .filtered-panel { grid-column: 1 / 3; grid-row: 2; } .mini-note { color: var(--muted); font-size: 0.72rem; } .status-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; } @media (max-width: 1380px) { body { overflow: auto; } .app { height: auto; min-height: 100vh; } .bar { grid-template-columns: repeat(2, minmax(0, 1fr)); } .workspace { grid-template-columns: 1fr; grid-template-rows: auto auto auto; } .raw-panel, .filtered-panel, .side-panel { grid-column: auto; grid-row: auto; } .side-panel { min-height: 720px; } } @media (max-width: 900px) { .signal-grid, .pointer-controls, .pointer-readout, .status-grid, .value-row { grid-template-columns: 1fr; } } </style> </head> <body> <div class="app"> <header class="bar"> <div class="title"> <strong>XIAO Wand Pointer Lab</strong> <span>Gravity-anchored vertical, hold-to-drag pointing, and BLE mouse output you can toggle from the UI.</span> </div> <div class="toolbar-group"> <button class="primary" id="connectBtn">Connect</button> <button class="secondary" id="disconnectBtn" disabled>Disconnect</button> <button class="ghost" id="clearBtn">Clear</button> <button class="ghost" id="mouseToggleBtn" disabled>Enable BLE Mouse</button> </div> <div class="toolbar-group"> <button class="primary" id="recenterBtn">Recenter Heading</button> <button class="ghost" id="rezeroBtn" disabled>Rezero Bias</button> </div> <label class="control"> Forward axis <select id="forwardAxisSelect"> <option value="+x">+x</option> <option value="-x">-x</option> <option value="+y">+y</option> <option value="-y">-y</option> <option value="+z" selected>+z</option> <option value="-z">-z</option> </select> </label> <div class="toolbar-group"> <span class="chip" id="connectionStatus">Disconnected</span> <span class="chip" id="streamStatus">Raw stream off</span> <span class="chip" id="mouseStatus">Mouse output off</span> <span class="chip" id="rateStatus">Sample rate n/a</span> <span class="chip" id="btBondChip">BT state n/a</span> <span class="chip" id="btModeChip">BT mode n/a</span> </div> <label class="control"> Pointer X gain (higher = less hand motion) <input id="pointerGainXInput" type="number" min="0.20" max="4.00" step="0.05" value="1.20"> </label> <label class="control"> Pointer Y gain (higher = less hand motion) <input id="pointerGainYInput" type="number" min="0.20" max="4.00" step="0.05" value="1.20"> </label> <label class="control"> Pointer smoothing alpha (lower = smoother) <input id="pointerSmoothInput" type="number" min="0.05" max="1.00" step="0.01" value="0.18"> </label> <label class="control"> Gyro LP alpha <input id="gyroAlphaInput" type="number" min="0.02" max="0.50" step="0.01" value="0.12"> </label> <label class="control"> Accel LP alpha <input id="accelAlphaInput" type="number" min="0.02" max="0.50" step="0.01" value="0.10"> </label> </header> <main class="workspace"> <section class="panel raw-panel"> <div class="panel-header"> <h2>Raw IMU</h2> <span>Drag the vector views to orbit. Use the wheel to zoom.</span> </div> <div class="signal-grid"> <article class="tile"> <div class="tile-head"><strong>Gyroscope Rate Vector</strong><span>instantaneous angular velocity</span></div> <canvas id="rawGyro3d"></canvas> <div class="value-row" id="rawGyroValues"></div> </article> <article class="tile"> <div class="tile-head"><strong>Accelerometer Vector</strong><span>`ax ay az` in g</span></div> <canvas id="rawAccel3d"></canvas> <div class="value-row" id="rawAccelValues"></div> </article> <article class="tile"> <div class="tile-head"><strong>Gyroscope Time Series</strong><span>recent samples</span></div> <canvas id="rawGyroSeries"></canvas> <div class="mini-note">Use this to see micro-jitter, turn onset, and saturation.</div> </article> <article class="tile"> <div class="tile-head"><strong>Accelerometer Time Series</strong><span>recent samples</span></div> <canvas id="rawAccelSeries"></canvas> <div class="mini-note">This is your gravity reference and lift/tilt signal.</div> </article> </div> </section> <section class="panel filtered-panel"> <div class="panel-header"> <h2>Low-Pass IMU</h2> <span>Browser-side low-pass only. Raw history stays untouched.</span> </div> <div class="signal-grid"> <article class="tile"> <div class="tile-head"><strong>Filtered Gyroscope Rate</strong><span>smoothed angular velocity</span></div> <canvas id="filteredGyro3d"></canvas> <div class="value-row" id="filteredGyroValues"></div> </article> <article class="tile"> <div class="tile-head"><strong>Filtered Accelerometer</strong><span>stable gravity trend</span></div> <canvas id="filteredAccel3d"></canvas> <div class="value-row" id="filteredAccelValues"></div> </article> <article class="tile"> <div class="tile-head"><strong>Filtered Gyroscope Series</strong><span>same window, smoothed</span></div> <canvas id="filteredGyroSeries"></canvas> <div class="mini-note">Use this to judge what the orientation solver will trust.</div> </article> <article class="tile"> <div class="tile-head"><strong>Filtered Accelerometer Series</strong><span>gravity after LP</span></div> <canvas id="filteredAccelSeries"></canvas> <div class="mini-note">This is what anchors pitch and roll against gravity.</div> </article> </div> </section> <aside class="panel side-panel"> <div class="panel-header"> <h2>Wand Pointer</h2> <span>Center heading with the button. Vertical stays tied to gravity.</span> </div> <article class="tile"> <div class="tile-head"><strong>Wand Geometry</strong><span>forward, center, right, up</span></div> <canvas id="orientationCanvas"></canvas> <div class="mini-note">This is the orientation view that should follow pointing. Raw gyro only shows turn rate while motion is happening.</div> </article> <div class="pointer-controls"> <div class="status-grid" id="pointerStatus"></div> <div class="button-row"> <button class="ghost" id="clearTrailBtn">Clear Trail</button> <button class="ghost" id="resetViewsBtn">Reset Views</button> </div> </div> <article class="tile"> <div class="tile-head"><strong>Bluetooth</strong><span>native multi-bond state without forcing old hosts to forget</span></div> <div class="status-grid" id="btStatus"></div> <div class="bt-actions"> <button class="ghost" id="btRefreshBtn" disabled>Refresh BT</button> <button class="ghost" id="btPairBtn" disabled>Enter Pair Mode</button> </div> </article> <div class="pointer-shell"> <canvas id="pointerCanvas"></canvas> <div class="pointer-readout" id="pointerReadout"></div> </div> <div class="log-box" id="deviceLog">No device log yet.</div> </aside> </main> </div> <script> const HISTORY_LIMIT = 960; const SERIES_WINDOW = 240; const DEG_TO_RAD = Math.PI / 180; const RAD_TO_DEG = 180 / Math.PI; const POINTER_RANGE = 1.15; const POINTER_DENOM_MIN = 0.18; const FUSION_KP = 1.9; const FUSION_KI = 0.06; const BUTTON_ARM_SETTLE_MS = 70; const AXIS_META = { gx: { label: 'gx', units: 'dps', color: '#d9480f', floor: 4 }, gy: { label: 'gy', units: 'dps', color: '#0f766e', floor: 4 }, gz: { label: 'gz', units: 'dps', color: '#4338ca', floor: 4 }, ax: { label: 'ax', units: 'g', color: '#6f9a37', floor: 0.25 }, ay: { label: 'ay', units: 'g', color: '#8b5cf6', floor: 0.25 }, az: { label: 'az', units: 'g', color: '#d97706', floor: 0.25 } }; const FORWARD_AXES = { '+x': [1, 0, 0], '-x': [-1, 0, 0], '+y': [0, 1, 0], '-y': [0, -1, 0], '+z': [0, 0, 1], '-z': [0, 0, -1] }; const DEFAULT_VIEW = { yaw: 0.8, pitch: -0.55, zoom: 1.1 }; const SERIAL_ENCODER = new TextEncoder(); const state = { port: null, reader: null, writer: null, decoderClosed: null, buffer: '', connected: false, closing: false, rawHistory: [], filteredHistory: [], latestRaw: null, latestFiltered: null, sampleRateHz: 0, mouseOffByTool: false, deviceMouseEnabled: false, btStatusPollId: null, view: {}, renderQueued: false, bt: { name: 'n/a', connected: false, currentBonds: null, mode: 'n/a', advertising: false, pairWindow: false }, fusion: { q: { w: 1, x: 0, y: 0, z: 0 }, integral: { x: 0, y: 0, z: 0 }, lastT: null, correction: 0, forwardWorld: [1, 0, 0], centerWorld: [1, 0, 0], hasCenter: false, pointer: { x: 0, y: 0 }, stable: false, motionArmed: false, buttonRaw: false, dragActive: false, dragReady: false, dragArmT: null, dragCenterWorld: [1, 0, 0], dragPointerAnchor: { x: 0, y: 0 }, trail: [] } }; const els = { connectBtn: document.getElementById('connectBtn'), disconnectBtn: document.getElementById('disconnectBtn'), clearBtn: document.getElementById('clearBtn'), mouseToggleBtn: document.getElementById('mouseToggleBtn'), recenterBtn: document.getElementById('recenterBtn'), rezeroBtn: document.getElementById('rezeroBtn'), clearTrailBtn: document.getElementById('clearTrailBtn'), resetViewsBtn: document.getElementById('resetViewsBtn'), connectionStatus: document.getElementById('connectionStatus'), streamStatus: document.getElementById('streamStatus'), mouseStatus: document.getElementById('mouseStatus'), rateStatus: document.getElementById('rateStatus'), btBondChip: document.getElementById('btBondChip'), btModeChip: document.getElementById('btModeChip'), gyroAlphaInput: document.getElementById('gyroAlphaInput'), accelAlphaInput: document.getElementById('accelAlphaInput'), pointerGainXInput: document.getElementById('pointerGainXInput'), pointerGainYInput: document.getElementById('pointerGainYInput'), pointerSmoothInput: document.getElementById('pointerSmoothInput'), forwardAxisSelect: document.getElementById('forwardAxisSelect'), rawGyro3d: document.getElementById('rawGyro3d'), rawAccel3d: document.getElementById('rawAccel3d'), filteredGyro3d: document.getElementById('filteredGyro3d'), filteredAccel3d: document.getElementById('filteredAccel3d'), rawGyroSeries: document.getElementById('rawGyroSeries'), rawAccelSeries: document.getElementById('rawAccelSeries'), filteredGyroSeries: document.getElementById('filteredGyroSeries'), filteredAccelSeries: document.getElementById('filteredAccelSeries'), rawGyroValues: document.getElementById('rawGyroValues'), rawAccelValues: document.getElementById('rawAccelValues'), filteredGyroValues: document.getElementById('filteredGyroValues'), filteredAccelValues: document.getElementById('filteredAccelValues'), orientationCanvas: document.getElementById('orientationCanvas'), pointerCanvas: document.getElementById('pointerCanvas'), pointerReadout: document.getElementById('pointerReadout'), pointerStatus: document.getElementById('pointerStatus'), btStatus: document.getElementById('btStatus'), btRefreshBtn: document.getElementById('btRefreshBtn'), btPairBtn: document.getElementById('btPairBtn'), deviceLog: document.getElementById('deviceLog') }; function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function formatSigned(value, digits = 3) { const number = Number(value ?? 0); return `${number >= 0 ? '+' : ''}${number.toFixed(digits)}`; } function formatBtMode(mode) { if (mode === 'pair_window') return 'pair window'; return mode; } function appendLog(line) { const stamp = new Date().toLocaleTimeString(); els.deviceLog.textContent = `[${stamp}] ${line}\n` + els.deviceLog.textContent; } function updateStatus() { els.connectionStatus.textContent = state.connected ? 'Connected' : 'Disconnected'; els.streamStatus.textContent = state.connected ? 'Raw stream requested' : 'Raw stream off'; els.mouseStatus.textContent = state.deviceMouseEnabled ? 'Mouse output on' : 'Mouse output off'; els.rateStatus.textContent = state.sampleRateHz > 0 ? `Sample rate ${state.sampleRateHz.toFixed(1)} Hz` : 'Sample rate n/a'; if (Number.isFinite(state.bt.currentBonds)) { els.btBondChip.textContent = state.bt.currentBonds > 0 ? 'BT bonded' : 'BT unpaired'; } else { els.btBondChip.textContent = 'BT state n/a'; } els.btModeChip.textContent = state.bt.pairWindow ? 'BT pair window active' : (state.bt.mode === 'n/a' ? 'BT mode n/a' : `BT ${formatBtMode(state.bt.mode)}`); els.disconnectBtn.disabled = !state.connected; els.rezeroBtn.disabled = !state.connected; els.mouseToggleBtn.disabled = !state.connected; els.btRefreshBtn.disabled = !state.connected; els.btPairBtn.disabled = !state.connected; els.mouseToggleBtn.textContent = state.deviceMouseEnabled ? 'Disable BLE Mouse' : 'Enable BLE Mouse'; } function resetBtState() { state.bt.name = 'n/a'; state.bt.connected = false; state.bt.currentBonds = null; state.bt.mode = 'n/a'; state.bt.advertising = false; state.bt.pairWindow = false; } function updateBtStatusFromLine(line) { let match = line.match(/^\[BT\] name=([^\s]+)\s+connected=(\d)\s+current_bonds=(\d+)\s+mode=(pairable|bonded|pair_window)\s+pair_window=(\d)$/); if (match) { state.bt.name = match[1]; state.bt.connected = match[2] === '1'; state.bt.currentBonds = Number(match[3]); state.bt.mode = match[4]; state.bt.pairWindow = match[5] === '1'; return true; } match = line.match(/^\[BT\] pairing_mode=1\s+name=([^\s]+)\s+current_bonds=(\d+)\s+pair_window_ms=(\d+)$/); if (match) { state.bt.name = match[1]; state.bt.currentBonds = Number(match[2]); state.bt.mode = 'pair_window'; state.bt.pairWindow = true; return true; } match = line.match(/^\[BLE\] Advertising started \(HID mouse\) name=([^\s]+)\s+mode=(pairable|bonded|pair_window)\s+pair_window=(\d)$/); if (match) { state.bt.name = match[1]; state.bt.mode = match[2]; state.bt.pairWindow = match[3] === '1'; state.bt.advertising = true; return true; } match = line.match(/^\[BT\] pairing_mode=0\s+current_bonds=(\d+)(?:\s+reason=([a-z_]+))?$/); if (match) { state.bt.currentBonds = Number(match[1]); state.bt.mode = state.bt.currentBonds === 0 ? 'pairable' : 'bonded'; state.bt.pairWindow = false; return true; } if (line.startsWith('[BLE] Connected:')) { state.bt.connected = true; state.bt.advertising = false; if (state.bt.mode === 'pair_window') { state.bt.pairWindow = true; } else { state.bt.pairWindow = false; state.bt.mode = (state.bt.currentBonds ?? 0) > 0 ? 'bonded' : 'pairable'; } return true; } if (line.startsWith('[BLE] Disconnected')) { state.bt.connected = false; state.bt.advertising = true; return true; } return false; } function getGyroAlpha() { return clamp(Number(els.gyroAlphaInput.value || 0.12), 0.02, 0.5); } function getAccelAlpha() { return clamp(Number(els.accelAlphaInput.value || 0.10), 0.02, 0.5); } function getPointerGainX() { return clamp(Number(els.pointerGainXInput.value || 1.2), 0.2, 4.0); } function getPointerGainY() { return clamp(Number(els.pointerGainYInput.value || 1.2), 0.2, 4.0); } function getPointerSmooth() { return clamp(Number(els.pointerSmoothInput.value || 0.18), 0.05, 1.0); } function normalizeVec3(vec) { const mag = Math.hypot(vec[0], vec[1], vec[2]); if (mag < 1e-9) return null; return [vec[0] / mag, vec[1] / mag, vec[2] / mag]; } function dotVec3(a, b) { return (a[0] * b[0]) + (a[1] * b[1]) + (a[2] * b[2]); } function crossVec3(a, b) { return [ (a[1] * b[2]) - (a[2] * b[1]), (a[2] * b[0]) - (a[0] * b[2]), (a[0] * b[1]) - (a[1] * b[0]) ]; } function scaleVec3(vec, scale) { return [vec[0] * scale, vec[1] * scale, vec[2] * scale]; } function subVec3(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; } function quatNormalize(q) { const mag = Math.hypot(q.w, q.x, q.y, q.z) || 1; return { w: q.w / mag, x: q.x / mag, y: q.y / mag, z: q.z / mag }; } function quatMultiply(a, b) { return { w: (a.w * b.w) - (a.x * b.x) - (a.y * b.y) - (a.z * b.z), x: (a.w * b.x) + (a.x * b.w) + (a.y * b.z) - (a.z * b.y), y: (a.w * b.y) - (a.x * b.z) + (a.y * b.w) + (a.z * b.x), z: (a.w * b.z) + (a.x * b.y) - (a.y * b.x) + (a.z * b.w) }; } function rotateVecByQuat(vec, quat) { const p = { w: 0, x: vec[0], y: vec[1], z: vec[2] }; const conjugate = { w: quat.w, x: -quat.x, y: -quat.y, z: -quat.z }; const rotated = quatMultiply(quatMultiply(quat, p), conjugate); return [rotated.x, rotated.y, rotated.z]; } function parseRawFrame(line) { if (!line.startsWith('RAW,')) return null; const parts = line.split(','); if (parts.length < 11) return null; const [_, t, gx, gy, gz, ax, ay, az, surfaceErr, active, motionArmed, buttonRaw] = parts; const sample = { t: Number(t), gx: Number(gx), gy: Number(gy), gz: Number(gz), ax: Number(ax), ay: Number(ay), az: Number(az), surface_err: Number(surfaceErr), active: Number(active), motion_armed: Number(motionArmed ?? 1), button_raw: Number(buttonRaw ?? 0) }; return Object.values(sample).every((value) => Number.isFinite(value)) ? sample : null; } function recentScale(history, keys, floor) { const recent = history.slice(-160); let max = floor; for (const sample of recent) { for (const key of keys) { max = Math.max(max, Math.abs(Number(sample[key] ?? 0))); } } return max * 1.18; } function lowPassSample(sample) { if (!state.latestFiltered) { return { ...sample }; } const gyroAlpha = getGyroAlpha(); const accelAlpha = getAccelAlpha(); return { t: sample.t, gx: state.latestFiltered.gx + ((sample.gx - state.latestFiltered.gx) * gyroAlpha), gy: state.latestFiltered.gy + ((sample.gy - state.latestFiltered.gy) * gyroAlpha), gz: state.latestFiltered.gz + ((sample.gz - state.latestFiltered.gz) * gyroAlpha), ax: state.latestFiltered.ax + ((sample.ax - state.latestFiltered.ax) * accelAlpha), ay: state.latestFiltered.ay + ((sample.ay - state.latestFiltered.ay) * accelAlpha), az: state.latestFiltered.az + ((sample.az - state.latestFiltered.az) * accelAlpha), surface_err: sample.surface_err, active: sample.active, motion_armed: sample.motion_armed, button_raw: sample.button_raw }; } function clearFusionState() { state.fusion.q = { w: 1, x: 0, y: 0, z: 0 }; state.fusion.integral = { x: 0, y: 0, z: 0 }; state.fusion.lastT = null; state.fusion.correction = 0; state.fusion.forwardWorld = [1, 0, 0]; state.fusion.centerWorld = [1, 0, 0]; state.fusion.hasCenter = false; state.fusion.pointer = { x: 0, y: 0 }; state.fusion.stable = false; state.fusion.motionArmed = false; state.fusion.buttonRaw = false; state.fusion.dragActive = false; state.fusion.dragReady = false; state.fusion.dragArmT = null; state.fusion.dragCenterWorld = [1, 0, 0]; state.fusion.dragPointerAnchor = { x: 0, y: 0 }; state.fusion.trail = []; } function updateFusion(sample, filtered) { if (!sample || !filtered) return; if (state.fusion.lastT == null) { state.fusion.lastT = sample.t; return; } let dt = (sample.t - state.fusion.lastT) / 1000; state.fusion.lastT = sample.t; if (!Number.isFinite(dt) || dt <= 0 || dt > 0.05) { dt = 0.008; } const accel = normalizeVec3([filtered.ax, filtered.ay, filtered.az]); let gx = sample.gx * DEG_TO_RAD; let gy = sample.gy * DEG_TO_RAD; let gz = sample.gz * DEG_TO_RAD; let { w, x, y, z } = state.fusion.q; if (accel) { const vx = 2 * ((x * z) - (w * y)); const vy = 2 * ((w * x) + (y * z)); const vz = (w * w) - (x * x) - (y * y) + (z * z); const ex = (accel[1] * vz) - (accel[2] * vy); const ey = (accel[2] * vx) - (accel[0] * vz); const ez = (accel[0] * vy) - (accel[1] * vx); state.fusion.correction = Math.hypot(ex, ey, ez); state.fusion.integral.x += ex * FUSION_KI * dt; state.fusion.integral.y += ey * FUSION_KI * dt; state.fusion.integral.z += ez * FUSION_KI * dt; gx += (FUSION_KP * ex) + state.fusion.integral.x; gy += (FUSION_KP * ey) + state.fusion.integral.y; gz += (FUSION_KP * ez) + state.fusion.integral.z; } const halfDt = 0.5 * dt; const qa = w; const qb = x; const qc = y; const qd = z; w += (-qb * gx - qc * gy - qd * gz) * halfDt; x += (qa * gx + qc * gz - qd * gy) * halfDt; y += (qa * gy - qb * gz + qd * gx) * halfDt; z += (qa * gz + qb * gy - qc * gx) * halfDt; state.fusion.q = quatNormalize({ w, x, y, z }); updatePointerProjection(); } function updatePointerProjection() { const up = [0, 0, 1]; const forwardBody = FORWARD_AXES[els.forwardAxisSelect.value] || FORWARD_AXES['+z']; const forwardWorld = normalizeVec3(rotateVecByQuat(forwardBody, state.fusion.q)) || [1, 0, 0]; state.fusion.forwardWorld = forwardWorld; if (!state.fusion.hasCenter) { const horizontal = subVec3(forwardWorld, scaleVec3(up, dotVec3(forwardWorld, up))); const center = normalizeVec3(horizontal); if (center) { state.fusion.centerWorld = center; state.fusion.hasCenter = true; } } if (!state.fusion.hasCenter) { requestRender(); return; } const motionArmed = Number(state.latestRaw?.motion_armed ?? 1) === 1; const buttonRaw = Number(state.latestRaw?.button_raw ?? 0) === 1; state.fusion.motionArmed = motionArmed; state.fusion.buttonRaw = buttonRaw; const gainX = getPointerGainX(); const gainY = getPointerGainY(); if (motionArmed && !state.fusion.dragActive) { state.fusion.dragActive = true; state.fusion.dragReady = false; state.fusion.dragArmT = state.latestRaw?.t ?? null; } else if (!motionArmed && state.fusion.dragActive) { state.fusion.dragActive = false; state.fusion.dragReady = false; state.fusion.dragArmT = null; } let projectionStable = false; if (motionArmed && state.fusion.dragActive) { const horizontal = subVec3(forwardWorld, scaleVec3(up, dotVec3(forwardWorld, up))); const centerNow = normalizeVec3(horizontal); if (centerNow) { state.fusion.dragCenterWorld = centerNow; } if (!state.fusion.dragReady) { state.fusion.dragPointerAnchor = { ...state.fusion.pointer }; const armedForMs = (state.latestRaw?.t ?? 0) - (state.fusion.dragArmT ?? state.latestRaw?.t ?? 0); if (armedForMs >= BUTTON_ARM_SETTLE_MS) { state.fusion.dragReady = true; } } const dragCenter = state.fusion.dragCenterWorld; const right = normalizeVec3(crossVec3(dragCenter, up)) || [1, 0, 0]; const denom = dotVec3(forwardWorld, dragCenter); projectionStable = denom > POINTER_DENOM_MIN; if (projectionStable && state.fusion.dragReady) { const projectedX = state.fusion.dragPointerAnchor.x + (gainX * (dotVec3(forwardWorld, right) / denom)); const projectedY = state.fusion.dragPointerAnchor.y + (gainY * (dotVec3(forwardWorld, up) / denom)); const smooth = getPointerSmooth(); state.fusion.pointer.x += (projectedX - state.fusion.pointer.x) * smooth; state.fusion.pointer.y += (projectedY - state.fusion.pointer.y) * smooth; } } state.fusion.stable = projectionStable && motionArmed && state.fusion.dragReady; state.fusion.trail.push({ x: state.fusion.pointer.x, y: state.fusion.pointer.y, stable: state.fusion.stable }); if (state.fusion.trail.length > 180) { state.fusion.trail.shift(); } requestRender(); } function recenterHeading() { if (!state.latestRaw) { appendLog('[UI] recenter skipped: no IMU samples yet'); return; } const up = [0, 0, 1]; const d = state.fusion.forwardWorld; if (!d) return; const horizontal = subVec3(d, scaleVec3(up, dotVec3(d, up))); const center = normalizeVec3(horizontal); if (!center) { appendLog('[UI] recenter skipped: forward ray too close to vertical'); return; } state.fusion.centerWorld = center; state.fusion.hasCenter = true; state.fusion.pointer = { x: 0, y: 0 }; state.fusion.trail = []; appendLog('[UI] heading recentered'); updatePointerProjection(); } function appendSample(sample) { state.latestRaw = sample; const filtered = lowPassSample(sample); state.latestFiltered = filtered; state.rawHistory.push(sample); state.filteredHistory.push(filtered); if (state.rawHistory.length > HISTORY_LIMIT) state.rawHistory.shift(); if (state.filteredHistory.length > HISTORY_LIMIT) state.filteredHistory.shift(); if (state.rawHistory.length > 8) { const first = state.rawHistory[state.rawHistory.length - 8].t; const last = state.rawHistory[state.rawHistory.length - 1].t; const span = Math.max(1, last - first); state.sampleRateHz = (7 * 1000) / span; } updateFusion(sample, filtered); updateStatus(); requestRender(); } function rebuildFilteredHistory() { const rawHistory = [...state.rawHistory]; state.filteredHistory = []; state.latestFiltered = null; clearFusionState(); for (const sample of rawHistory) { const filtered = lowPassSample(sample); state.latestFiltered = filtered; state.filteredHistory.push(filtered); updateFusion(sample, filtered); } requestRender(); } function resizeCanvasToDisplay(canvas) { const dpr = window.devicePixelRatio || 1; const width = Math.max(80, Math.round(canvas.clientWidth * dpr)); const height = Math.max(80, Math.round(canvas.clientHeight * dpr)); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } return { width, height, dpr }; } function getViewState(key) { if (!state.view[key]) { state.view[key] = { ...DEFAULT_VIEW, dragging: false, lastX: 0, lastY: 0 }; } return state.view[key]; } function resetViewState(key) { const view = getViewState(key); view.yaw = DEFAULT_VIEW.yaw; view.pitch = DEFAULT_VIEW.pitch; view.zoom = DEFAULT_VIEW.zoom; view.dragging = false; view.lastX = 0; view.lastY = 0; } function rotateForView(vec, view) { const [x, y, z] = vec; const cy = Math.cos(view.yaw); const sy = Math.sin(view.yaw); const cp = Math.cos(view.pitch); const sp = Math.sin(view.pitch); const x1 = (cy * x) - (sy * y); const y1 = (sy * x) + (cy * y); const z1 = z; const y2 = (cp * y1) - (sp * z1); const z2 = (sp * y1) + (cp * z1); return [x1, y2, z2]; } function project3D(vec, view, scale, width, height) { const rotated = rotateForView(vec, view); const perspective = 1 / Math.max(0.45, 1.18 - (rotated[2] / (scale || 1)) * 0.35); const px = (width / 2) + ((rotated[0] / scale) * width * 0.22 * view.zoom * perspective); const py = (height / 2) - ((rotated[1] / scale) * height * 0.22 * view.zoom * perspective); return { x: px, y: py }; } function draw3DVector(canvas, history, sample, keys, floor, viewKey) { const { width, height } = resizeCanvasToDisplay(canvas); const ctx = canvas.getContext('2d'); const view = getViewState(viewKey); ctx.clearRect(0, 0, width, height); if (!sample) return; const scale = recentScale(history, keys, floor); const origin = { x: width / 2, y: height / 2 }; const axisVectors = [ { vec: [scale, 0, 0], color: AXIS_META[keys[0]].color, label: keys[0] }, { vec: [0, scale, 0], color: AXIS_META[keys[1]].color, label: keys[1] }, { vec: [0, 0, scale], color: AXIS_META[keys[2]].color, label: keys[2] } ]; ctx.strokeStyle = 'rgba(31,25,18,0.10)'; ctx.lineWidth = 1; for (const ring of [0.25, 0.5, 0.75, 1]) { ctx.beginPath(); const points = [ [scale * ring, 0, 0], [0, scale * ring, 0], [-scale * ring, 0, 0], [0, -scale * ring, 0], [scale * ring, 0, 0] ]; points.forEach((vec, index) => { const point = project3D(vec, view, scale, width, height); if (index === 0) ctx.moveTo(point.x, point.y); else ctx.lineTo(point.x, point.y); }); ctx.stroke(); } for (const axis of axisVectors) { const point = project3D(axis.vec, view, scale, width, height); ctx.strokeStyle = axis.color; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(origin.x, origin.y); ctx.lineTo(point.x, point.y); ctx.stroke(); ctx.fillStyle = axis.color; ctx.font = `${Math.max(10, width * 0.022)}px Georgia`; ctx.fillText(axis.label, point.x + 4, point.y - 4); } const pointer = project3D([sample[keys[0]], sample[keys[1]], sample[keys[2]]], view, scale, width, height); ctx.strokeStyle = '#111827'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(origin.x, origin.y); ctx.lineTo(pointer.x, pointer.y); ctx.stroke(); ctx.fillStyle = '#111827'; ctx.beginPath(); ctx.arc(pointer.x, pointer.y, Math.max(4, width * 0.012), 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(31,25,18,0.54)'; ctx.font = `${Math.max(10, width * 0.022)}px Georgia`; ctx.fillText(`scale +/-${scale.toFixed(2)}`, 10, 18); ctx.fillText(`orbit drag | wheel zoom`, 10, height - 10); } function drawTimeSeries(canvas, history, keys, floor) { const { width, height } = resizeCanvasToDisplay(canvas); const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, width, height); if (history.length < 2) return; const recent = history.slice(-SERIES_WINDOW); const scale = recentScale(recent, keys, floor); const zeroY = height / 2; ctx.strokeStyle = 'rgba(31,25,18,0.12)'; ctx.lineWidth = 1; for (let i = 1; i < 4; i++) { const y = (height / 4) * i; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); } ctx.strokeStyle = 'rgba(31,25,18,0.22)'; ctx.beginPath(); ctx.moveTo(0, zeroY); ctx.lineTo(width, zeroY); ctx.stroke(); keys.forEach((key) => { ctx.strokeStyle = AXIS_META[key].color; ctx.lineWidth = 1.8; ctx.beginPath(); recent.forEach((sample, index) => { const x = (index / Math.max(1, recent.length - 1)) * width; const y = zeroY - ((sample[key] / scale) * (height * 0.40)); if (index === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); }); ctx.fillStyle = 'rgba(31,25,18,0.54)'; ctx.font = `${Math.max(10, width * 0.018)}px Georgia`; ctx.fillText(`scale +/-${scale.toFixed(2)}`, 10, 18); } function renderValues(container, sample, keys) { if (!sample) { container.innerHTML = '<div class="value-pill"><span>No data</span><strong>--</strong></div>'; return; } container.innerHTML = keys.map((key) => { const digits = AXIS_META[key].units === 'g' ? 4 : 3; return ` <div class="value-pill"> <span>${AXIS_META[key].label}</span> <strong>${formatSigned(sample[key], digits)} ${AXIS_META[key].units}</strong> </div> `; }).join(''); } function renderPointerStatus() { const d = state.fusion.forwardWorld; const c = state.fusion.centerWorld; const up = [0, 0, 1]; const right = normalizeVec3(crossVec3(c, up)) || [1, 0, 0]; const heading = Math.atan2(dotVec3(d, right), dotVec3(d, c)) * RAD_TO_DEG; const elevation = Math.asin(clamp(dotVec3(d, up), -1, 1)) * RAD_TO_DEG; const denom = dotVec3(d, c); els.pointerStatus.innerHTML = ` <div class="value-pill"><span>heading</span><strong>${formatSigned(heading, 1)} deg</strong></div> <div class="value-pill"><span>elevation</span><strong>${formatSigned(elevation, 1)} deg</strong></div> <div class="value-pill"><span>projection</span><strong>${denom.toFixed(3)}</strong></div> <div class="value-pill"><span>motion</span><strong>${state.fusion.motionArmed ? 'armed' : 'held'}</strong></div> <div class="value-pill"><span>button raw</span><strong>${state.fusion.buttonRaw ? '1 down' : '0 up'}</strong></div> <div class="value-pill"><span>fusion error</span><strong>${state.fusion.correction.toFixed(3)}</strong></div> `; } function renderPointerReadout() { const d = state.fusion.forwardWorld; const digits = 3; els.pointerReadout.innerHTML = ` <div class="value-pill"><span>pointer x</span><strong>${formatSigned(state.fusion.pointer.x, digits)}</strong></div> <div class="value-pill"><span>pointer y</span><strong>${formatSigned(state.fusion.pointer.y, digits)}</strong></div> <div class="value-pill"><span>stable</span><strong>${state.fusion.dragActive && !state.fusion.dragReady ? 'settling' : (state.fusion.stable ? 'yes' : 'hold')}</strong></div> <div class="value-pill"><span>forward ray</span><strong>${formatSigned(d[0], 2)}, ${formatSigned(d[1], 2)}, ${formatSigned(d[2], 2)}</strong></div> <div class="value-pill"><span>surface gate</span><strong>${Number(state.latestRaw?.active ?? 0) === 1 ? 'engaged' : 'clutched'}</strong></div> <div class="value-pill"><span>button raw</span><strong>${state.fusion.buttonRaw ? 'pressed' : 'released'}</strong></div> `; } function renderBtStatus() { const currentBonds = Number.isFinite(state.bt.currentBonds) ? String(state.bt.currentBonds) : '--'; const advertising = state.bt.advertising ? 'yes' : 'no'; els.btStatus.innerHTML = ` <div class="value-pill"><span>device name</span><strong>${state.bt.name}</strong></div> <div class="value-pill"><span>bond mode</span><strong>${formatBtMode(state.bt.mode)}</strong></div> <div class="value-pill"><span>saved bonds</span><strong>${currentBonds}</strong></div> <div class="value-pill"><span>BLE connected</span><strong>${state.bt.connected ? 'yes' : 'no'}</strong></div> <div class="value-pill"><span>advertising</span><strong>${advertising}</strong></div> <div class="value-pill"><span>pair window</span><strong>${state.bt.pairWindow ? 'active' : 'idle'}</strong></div> <div class="value-pill"><span>D1 short</span><strong>refresh status</strong></div> <div class="value-pill"><span>D1 hold</span><strong>hide bonds + 30s pair</strong></div> `; } function drawPointerCanvas() { const { width, height } = resizeCanvasToDisplay(els.pointerCanvas); const ctx = els.pointerCanvas.getContext('2d'); ctx.clearRect(0, 0, width, height); const cx = width / 2; const cy = height / 2; const radius = Math.min(width, height) * 0.42; ctx.strokeStyle = 'rgba(31,25,18,0.12)'; ctx.lineWidth = 1; for (const ratio of [0.25, 0.5, 0.75, 1]) { ctx.strokeRect(cx - (radius * ratio), cy - (radius * ratio), radius * ratio * 2, radius * ratio * 2); } ctx.beginPath(); ctx.moveTo(cx, 12); ctx.lineTo(cx, height - 12); ctx.moveTo(12, cy); ctx.lineTo(width - 12, cy); ctx.stroke(); const trail = state.fusion.trail; if (trail.length > 1) { ctx.strokeStyle = 'rgba(15, 106, 92, 0.28)'; ctx.lineWidth = 2; ctx.beginPath(); trail.forEach((point, index) => { const x = cx + (clamp(point.x, -POINTER_RANGE, POINTER_RANGE) / POINTER_RANGE) * radius; const y = cy - (clamp(point.y, -POINTER_RANGE, POINTER_RANGE) / POINTER_RANGE) * radius; if (index === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); } const px = cx + (clamp(state.fusion.pointer.x, -POINTER_RANGE, POINTER_RANGE) / POINTER_RANGE) * radius; const py = cy - (clamp(state.fusion.pointer.y, -POINTER_RANGE, POINTER_RANGE) / POINTER_RANGE) * radius; ctx.fillStyle = state.fusion.stable ? '#111827' : 'rgba(17,24,39,0.35)'; ctx.beginPath(); ctx.arc(px, py, Math.max(6, width * 0.02), 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(31,25,18,0.58)'; ctx.font = `${Math.max(10, width * 0.026)}px Georgia`; ctx.fillText('gravity up', 10, 18); const statusText = state.fusion.motionArmed ? (state.fusion.dragActive && !state.fusion.dragReady ? 'settling press' : (state.fusion.stable ? 'projection stable' : 'projection held')) : 'motion held by button'; ctx.fillText(statusText, 10, height - 12); } function drawOrientation3D() { const canvas = els.orientationCanvas; const { width, height } = resizeCanvasToDisplay(canvas); const ctx = canvas.getContext('2d'); const view = getViewState('orientation3d'); ctx.clearRect(0, 0, width, height); const scale = 1.0; const origin = { x: width / 2, y: height / 2 }; const up = [0, 0, 1]; const center = state.fusion.centerWorld; const right = normalizeVec3(crossVec3(center, up)) || [1, 0, 0]; const vectors = [ { label: 'up', vec: up, color: '#a94f25', width: 2.2 }, { label: 'right', vec: right, color: '#355070', width: 2.2 }, { label: 'center', vec: center, color: '#0f6a5c', width: 2.4 }, { label: 'forward', vec: state.fusion.forwardWorld, color: '#111827', width: 3.0 } ]; ctx.strokeStyle = 'rgba(31,25,18,0.10)'; ctx.lineWidth = 1; for (const ring of [0.35, 0.7, 1.0]) { ctx.beginPath(); const points = [ [ring, 0, 0], [0, ring, 0], [-ring, 0, 0], [0, -ring, 0], [ring, 0, 0] ]; points.forEach((vec, index) => { const point = project3D(vec, view, scale, width, height); if (index === 0) ctx.moveTo(point.x, point.y); else ctx.lineTo(point.x, point.y); }); ctx.stroke(); } for (const vector of vectors) { const point = project3D(vector.vec, view, scale, width, height); ctx.strokeStyle = vector.color; ctx.lineWidth = vector.width; ctx.beginPath(); ctx.moveTo(origin.x, origin.y); ctx.lineTo(point.x, point.y); ctx.stroke(); ctx.fillStyle = vector.color; ctx.font = `${Math.max(10, width * 0.026)}px Georgia`; ctx.fillText(vector.label, point.x + 4, point.y - 4); } ctx.fillStyle = 'rgba(31,25,18,0.54)'; ctx.font = `${Math.max(10, width * 0.022)}px Georgia`; ctx.fillText('wand basis', 10, 18); ctx.fillText('orbit drag | wheel zoom', 10, height - 10); } function renderAll() { state.renderQueued = false; renderValues(els.rawGyroValues, state.latestRaw, ['gx', 'gy', 'gz']); renderValues(els.rawAccelValues, state.latestRaw, ['ax', 'ay', 'az']); renderValues(els.filteredGyroValues, state.latestFiltered, ['gx', 'gy', 'gz']); renderValues(els.filteredAccelValues, state.latestFiltered, ['ax', 'ay', 'az']); draw3DVector(els.rawGyro3d, state.rawHistory, state.latestRaw, ['gx', 'gy', 'gz'], 6, 'rawGyro3d'); draw3DVector(els.rawAccel3d, state.rawHistory, state.latestRaw, ['ax', 'ay', 'az'], 0.4, 'rawAccel3d'); draw3DVector(els.filteredGyro3d, state.filteredHistory, state.latestFiltered, ['gx', 'gy', 'gz'], 6, 'filteredGyro3d'); draw3DVector(els.filteredAccel3d, state.filteredHistory, state.latestFiltered, ['ax', 'ay', 'az'], 0.4, 'filteredAccel3d'); drawTimeSeries(els.rawGyroSeries, state.rawHistory, ['gx', 'gy', 'gz'], 6); drawTimeSeries(els.rawAccelSeries, state.rawHistory, ['ax', 'ay', 'az'], 0.4); drawTimeSeries(els.filteredGyroSeries, state.filteredHistory, ['gx', 'gy', 'gz'], 6); drawTimeSeries(els.filteredAccelSeries, state.filteredHistory, ['ax', 'ay', 'az'], 0.4); drawOrientation3D(); drawPointerCanvas(); renderPointerStatus(); renderPointerReadout(); renderBtStatus(); } function requestRender() { if (state.renderQueued) return; state.renderQueued = true; requestAnimationFrame(renderAll); } async function sendCommand(command) { if (!state.writer) return; await state.writer.write(SERIAL_ENCODER.encode(`${command}\n`)); } async function syncDeviceWandConfig() { if (!state.connected) return; await sendCommand(`SET FORWARD_AXIS ${els.forwardAxisSelect.value}`); await sendCommand(`SET WAND_GAIN_X ${getPointerGainX().toFixed(2)}`); await sendCommand(`SET WAND_GAIN_Y ${getPointerGainY().toFixed(2)}`); await sendCommand(`SET WAND_SMOOTH ${getPointerSmooth().toFixed(2)}`); } async function refreshBtStatus() { if (!state.connected) return; await sendCommand('BT STATUS'); } async function setDeviceMouseEnabled(enabled) { if (!state.connected) return; await sendCommand(enabled ? 'MOUSE ON' : 'MOUSE OFF'); state.deviceMouseEnabled = enabled; state.mouseOffByTool = !enabled; updateStatus(); } async function connectBoard() { if (!('serial' in navigator)) { throw new Error('Web Serial is not available in this browser.'); } try { state.port = await navigator.serial.requestPort(); await state.port.open({ baudRate: 115200 }); const decoder = new TextDecoderStream(); state.decoderClosed = state.port.readable.pipeTo(decoder.writable); state.reader = decoder.readable.getReader(); state.writer = state.port.writable.getWriter(); state.connected = true; state.mouseOffByTool = true; state.deviceMouseEnabled = false; updateStatus(); appendLog('[UI] serial connected'); await setDeviceMouseEnabled(false); await sendCommand('RAW ON'); await syncDeviceWandConfig(); await sendCommand('STATUS'); await refreshBtStatus(); state.btStatusPollId = window.setInterval(() => { refreshBtStatus().catch((error) => console.error(error)); }, 1500); while (state.connected && !state.closing) { const { value, done } = await state.reader.read(); if (done) break; state.buffer += value; const lines = state.buffer.split('\n'); state.buffer = lines.pop(); for (const rawLine of lines) { const line = rawLine.trim(); if (!line) continue; const frame = parseRawFrame(line); if (frame) { appendSample(frame); } else { if (line.startsWith('[BTN] recenter')) { recenterHeading(); } updateBtStatusFromLine(line); appendLog(line); if (line.includes('mouse output disabled')) { state.mouseOffByTool = true; state.deviceMouseEnabled = false; } if (line.includes('mouse output enabled')) { state.mouseOffByTool = false; state.deviceMouseEnabled = true; } if (line.includes('motion=armed')) state.fusion.motionArmed = true; if (line.includes('motion=held')) state.fusion.motionArmed = false; updateStatus(); requestRender(); } } } if (state.connected && !state.closing) { appendLog('[UI] serial stream ended'); await disconnectBoard(); } } catch (error) { appendLog(`[UI] connect failed: ${error.message}`); await disconnectBoard(); throw error; } } async function disconnectBoard() { if (!state.port) return; state.closing = true; if (state.btStatusPollId) { window.clearInterval(state.btStatusPollId); state.btStatusPollId = null; } try { await sendCommand('RAW OFF'); await sendCommand('MOUSE ON'); state.mouseOffByTool = false; state.deviceMouseEnabled = true; } catch (error) { console.error(error); } try { if (state.reader) { await state.reader.cancel(); state.reader.releaseLock(); state.reader = null; } } catch (error) { console.error(error); } try { if (state.writer) { state.writer.releaseLock(); state.writer = null; } } catch (error) { console.error(error); } try { await state.decoderClosed; } catch (error) { console.error(error); } try { await state.port.close(); } catch (error) { console.error(error); } state.port = null; state.connected = false; state.closing = false; state.buffer = ''; state.deviceMouseEnabled = false; resetBtState(); updateStatus(); requestRender(); appendLog('[UI] serial disconnected'); } function clearHistory() { state.rawHistory = []; state.filteredHistory = []; state.latestRaw = null; state.latestFiltered = null; state.sampleRateHz = 0; clearFusionState(); updateStatus(); requestRender(); } function clearTrail() { state.fusion.trail = []; state.fusion.pointer = { x: 0, y: 0 }; requestRender(); } function resetViews() { ['rawGyro3d', 'rawAccel3d', 'filteredGyro3d', 'filteredAccel3d', 'orientation3d'].forEach((key) => { resetViewState(key); }); requestRender(); } function attachOrbitControls(canvas, key) { canvas.addEventListener('pointerdown', (event) => { const view = getViewState(key); view.dragging = true; view.lastX = event.clientX; view.lastY = event.clientY; canvas.setPointerCapture(event.pointerId); }); canvas.addEventListener('pointermove', (event) => { const view = getViewState(key); if (!view.dragging) return; const dx = event.clientX - view.lastX; const dy = event.clientY - view.lastY; view.lastX = event.clientX; view.lastY = event.clientY; view.yaw += dx * 0.010; view.pitch = clamp(view.pitch + (dy * 0.010), -1.35, 1.35); requestRender(); }); const stopDrag = () => { const view = getViewState(key); view.dragging = false; }; canvas.addEventListener('pointerup', stopDrag); canvas.addEventListener('pointercancel', stopDrag); canvas.addEventListener('wheel', (event) => { const view = getViewState(key); event.preventDefault(); view.zoom = clamp(view.zoom * Math.exp(-event.deltaY * 0.0012), 0.65, 2.8); requestRender(); }, { passive: false }); canvas.addEventListener('dblclick', () => { resetViewState(key); requestRender(); }); } els.connectBtn.addEventListener('click', async () => { try { await connectBoard(); } catch (error) { console.error(error); alert(`Connection failed: ${error.message}`); } }); els.disconnectBtn.addEventListener('click', async () => { await disconnectBoard(); }); els.clearBtn.addEventListener('click', clearHistory); els.mouseToggleBtn.addEventListener('click', async () => { await setDeviceMouseEnabled(!state.deviceMouseEnabled); }); els.btRefreshBtn.addEventListener('click', async () => { await refreshBtStatus(); }); els.btPairBtn.addEventListener('click', async () => { appendLog('[UI] BT PAIR requested; device will disconnect, hide all saved bonds, and keep the pair window open until a secured new connection or 30s timeout'); await sendCommand('BT PAIR'); window.setTimeout(() => { refreshBtStatus().catch((error) => console.error(error)); }, 200); }); els.recenterBtn.addEventListener('click', recenterHeading); els.rezeroBtn.addEventListener('click', async () => { await sendCommand('REZERO'); appendLog('[UI] sent REZERO'); }); els.clearTrailBtn.addEventListener('click', clearTrail); els.resetViewsBtn.addEventListener('click', resetViews); [els.gyroAlphaInput, els.accelAlphaInput].forEach((input) => { input.addEventListener('input', () => { if (state.rawHistory.length) rebuildFilteredHistory(); }); }); [els.pointerGainXInput, els.pointerGainYInput, els.pointerSmoothInput, els.forwardAxisSelect].forEach((input) => { input.addEventListener('input', () => { updatePointerProjection(); if (state.connected) { syncDeviceWandConfig().catch((error) => console.error(error)); } }); }); window.addEventListener('resize', requestRender); attachOrbitControls(els.rawGyro3d, 'rawGyro3d'); attachOrbitControls(els.rawAccel3d, 'rawAccel3d'); attachOrbitControls(els.filteredGyro3d, 'filteredGyro3d'); attachOrbitControls(els.filteredAccel3d, 'filteredAccel3d'); attachOrbitControls(els.orientationCanvas, 'orientation3d'); updateStatus(); resetBtState(); clearFusionState(); requestRender(); </script> </body> </html>
No revisions found. Save the file to create a backup.
Delete
Update App