/** * SensingTab — Live WiFi Sensing Visualization * * Connects to the sensing WebSocket service and renders: * 1. A 3D Gaussian-splat signal field (via gaussian-splats.js) * 2. An overlay HUD with real-time metrics (RSSI, variance, bands, classification) */ import { sensingService } from '../services/sensing.service.js'; import { GaussianSplatRenderer } from './gaussian-splats.js'; export class SensingTab { /** @param {HTMLElement} container - the #sensing section element */ constructor(container) { this.container = container; this.splatRenderer = null; this._unsubData = null; this._unsubState = null; this._resizeObserver = null; this._threeLoaded = false; } async init() { this._buildDOM(); await this._loadThree(); this._initSplatRenderer(); this._connectService(); this._setupResize(); } // ---- DOM construction -------------------------------------------------- _buildDOM() { this.container.innerHTML = `

Live WiFi Sensing

Loading 3D engine...
Connection
Connecting...
RSSI
-- dBm
Signal Features
0
0
0
0
Classification
ABSENT
0%
Details
Dominant Freq0 Hz
Change Points0
Sample Rate--
`; } // ---- Three.js loading -------------------------------------------------- async _loadThree() { if (window.THREE) { this._threeLoaded = true; return; } return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js'; script.onload = () => { this._threeLoaded = true; resolve(); }; script.onerror = () => reject(new Error('Failed to load Three.js')); document.head.appendChild(script); }); } // ---- Splat renderer ---------------------------------------------------- _initSplatRenderer() { const viewport = this.container.querySelector('#sensingViewport'); if (!viewport) return; // Remove loading message viewport.innerHTML = ''; try { this.splatRenderer = new GaussianSplatRenderer(viewport, { width: viewport.clientWidth, height: viewport.clientHeight || 500, }); } catch (e) { console.error('[SensingTab] Failed to init splat renderer:', e); viewport.innerHTML = '
3D rendering unavailable
'; } } // ---- Service connection ------------------------------------------------ _connectService() { sensingService.start(); this._unsubData = sensingService.onData((data) => this._onSensingData(data)); this._unsubState = sensingService.onStateChange((state) => this._onStateChange(state)); } _onSensingData(data) { // Update 3D view if (this.splatRenderer) { this.splatRenderer.update(data); } // Update HUD this._updateHUD(data); } _onStateChange(state) { const dot = this.container.querySelector('#sensingDot'); const text = this.container.querySelector('#sensingState'); if (!dot || !text) return; const labels = { disconnected: 'Disconnected', connecting: 'Connecting...', connected: 'Connected', simulated: 'Simulated', }; dot.className = 'sensing-dot ' + state; text.textContent = labels[state] || state; } // ---- HUD update -------------------------------------------------------- _updateHUD(data) { const f = data.features || {}; const c = data.classification || {}; // RSSI this._setText('sensingRssi', `${(f.mean_rssi || -80).toFixed(1)} dBm`); this._setText('sensingSource', data.source || ''); // Bars (scale to 0-100%) this._setBar('barVariance', f.variance, 10, 'valVariance', f.variance); this._setBar('barMotion', f.motion_band_power, 0.5, 'valMotion', f.motion_band_power); this._setBar('barBreath', f.breathing_band_power, 0.3, 'valBreath', f.breathing_band_power); this._setBar('barSpectral', f.spectral_power, 2.0, 'valSpectral', f.spectral_power); // Classification const label = this.container.querySelector('#classLabel'); if (label) { const level = (c.motion_level || 'absent').toUpperCase(); label.textContent = level; label.className = 'sensing-class-label ' + (c.motion_level || 'absent'); } const confPct = ((c.confidence || 0) * 100).toFixed(0); this._setBar('barConfidence', c.confidence, 1.0, 'valConfidence', confPct + '%'); // Details this._setText('valDomFreq', (f.dominant_freq_hz || 0).toFixed(3) + ' Hz'); this._setText('valChangePoints', String(f.change_points || 0)); this._setText('valSampleRate', data.source === 'simulated' ? 'sim' : 'live'); // Sparkline this._drawSparkline(); } _setText(id, text) { const el = this.container.querySelector('#' + id); if (el) el.textContent = text; } _setBar(barId, value, maxVal, valId, displayVal) { const bar = this.container.querySelector('#' + barId); if (bar) { const pct = Math.min(100, Math.max(0, ((value || 0) / maxVal) * 100)); bar.style.width = pct + '%'; } if (valId && displayVal != null) { const el = this.container.querySelector('#' + valId); if (el) el.textContent = typeof displayVal === 'number' ? displayVal.toFixed(3) : displayVal; } } _drawSparkline() { const canvas = this.container.querySelector('#sensingSparkline'); if (!canvas) return; const ctx = canvas.getContext('2d'); const history = sensingService.getRssiHistory(); if (history.length < 2) return; const w = canvas.width; const h = canvas.height; ctx.clearRect(0, 0, w, h); const min = Math.min(...history) - 2; const max = Math.max(...history) + 2; const range = max - min || 1; ctx.beginPath(); ctx.strokeStyle = '#32b8c6'; ctx.lineWidth = 1.5; for (let i = 0; i < history.length; i++) { const x = (i / (history.length - 1)) * w; const y = h - ((history[i] - min) / range) * h; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); } // ---- Resize ------------------------------------------------------------ _setupResize() { const viewport = this.container.querySelector('#sensingViewport'); if (!viewport || !window.ResizeObserver) return; this._resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { if (this.splatRenderer) { this.splatRenderer.resize(entry.contentRect.width, entry.contentRect.height); } } }); this._resizeObserver.observe(viewport); } // ---- Cleanup ----------------------------------------------------------- dispose() { if (this._unsubData) this._unsubData(); if (this._unsubState) this._unsubState(); if (this._resizeObserver) this._resizeObserver.disconnect(); if (this.splatRenderer) this.splatRenderer.dispose(); sensingService.stop(); } }