feat: Sensing-only UI mode with Gaussian splat visualization and Rust migration ADR
- Add Python WebSocket sensing server (ws_server.py) with ESP32 UDP CSI and Windows RSSI auto-detect collectors on port 8765 - Add Three.js Gaussian splat renderer with custom GLSL shaders for real-time WiFi signal field visualization (blue→green→red gradient) - Add SensingTab component with RSSI sparkline, feature meters, and motion classification badge - Add sensing.service.js WebSocket client with reconnect and simulation fallback - Implement sensing-only mode: suppress all DensePose API calls when FastAPI backend (port 8000) is not running, clean console output - ADR-019: Document sensing-only UI architecture and data flow - ADR-020: Migrate AI/model inference to Rust with RuVector ONNX Runtime, replacing ~2.7GB Python stack with ~50MB static binary - Add ruvnet/ruvector as upstream remote for RuVector crate ecosystem Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -51,8 +51,8 @@ export class DashboardTab {
|
||||
this.updateStats(stats);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error);
|
||||
this.showError('Failed to load dashboard data');
|
||||
// DensePose API may not be running (sensing-only mode) — fail silently
|
||||
console.log('Dashboard: DensePose API not available (sensing-only mode)');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
302
ui/components/SensingTab.js
Normal file
302
ui/components/SensingTab.js
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 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 = `
|
||||
<h2>Live WiFi Sensing</h2>
|
||||
<div class="sensing-layout">
|
||||
<!-- 3D viewport -->
|
||||
<div class="sensing-viewport" id="sensingViewport">
|
||||
<div class="sensing-loading">Loading 3D engine...</div>
|
||||
</div>
|
||||
|
||||
<!-- Side panel -->
|
||||
<div class="sensing-panel">
|
||||
<!-- Connection -->
|
||||
<div class="sensing-card">
|
||||
<div class="sensing-card-title">Connection</div>
|
||||
<div class="sensing-connection">
|
||||
<span class="sensing-dot" id="sensingDot"></span>
|
||||
<span id="sensingState">Connecting...</span>
|
||||
<span class="sensing-source" id="sensingSource"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSSI -->
|
||||
<div class="sensing-card">
|
||||
<div class="sensing-card-title">RSSI</div>
|
||||
<div class="sensing-big-value" id="sensingRssi">-- dBm</div>
|
||||
<canvas id="sensingSparkline" width="200" height="40"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Signal Features -->
|
||||
<div class="sensing-card">
|
||||
<div class="sensing-card-title">Signal Features</div>
|
||||
<div class="sensing-meters">
|
||||
<div class="sensing-meter">
|
||||
<label>Variance</label>
|
||||
<div class="sensing-bar"><div class="sensing-bar-fill" id="barVariance"></div></div>
|
||||
<span class="sensing-meter-val" id="valVariance">0</span>
|
||||
</div>
|
||||
<div class="sensing-meter">
|
||||
<label>Motion Band</label>
|
||||
<div class="sensing-bar"><div class="sensing-bar-fill motion" id="barMotion"></div></div>
|
||||
<span class="sensing-meter-val" id="valMotion">0</span>
|
||||
</div>
|
||||
<div class="sensing-meter">
|
||||
<label>Breathing Band</label>
|
||||
<div class="sensing-bar"><div class="sensing-bar-fill breath" id="barBreath"></div></div>
|
||||
<span class="sensing-meter-val" id="valBreath">0</span>
|
||||
</div>
|
||||
<div class="sensing-meter">
|
||||
<label>Spectral Power</label>
|
||||
<div class="sensing-bar"><div class="sensing-bar-fill spectral" id="barSpectral"></div></div>
|
||||
<span class="sensing-meter-val" id="valSpectral">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Classification -->
|
||||
<div class="sensing-card">
|
||||
<div class="sensing-card-title">Classification</div>
|
||||
<div class="sensing-classification" id="sensingClassification">
|
||||
<div class="sensing-class-label" id="classLabel">ABSENT</div>
|
||||
<div class="sensing-confidence">
|
||||
<label>Confidence</label>
|
||||
<div class="sensing-bar"><div class="sensing-bar-fill confidence" id="barConfidence"></div></div>
|
||||
<span class="sensing-meter-val" id="valConfidence">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extra info -->
|
||||
<div class="sensing-card">
|
||||
<div class="sensing-card-title">Details</div>
|
||||
<div class="sensing-details">
|
||||
<div class="sensing-detail-row">
|
||||
<span>Dominant Freq</span><span id="valDomFreq">0 Hz</span>
|
||||
</div>
|
||||
<div class="sensing-detail-row">
|
||||
<span>Change Points</span><span id="valChangePoints">0</span>
|
||||
</div>
|
||||
<div class="sensing-detail-row">
|
||||
<span>Sample Rate</span><span id="valSampleRate">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ---- 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 = '<div class="sensing-loading">3D rendering unavailable</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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();
|
||||
}
|
||||
}
|
||||
412
ui/components/gaussian-splats.js
Normal file
412
ui/components/gaussian-splats.js
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Gaussian Splat Renderer for WiFi Sensing Visualization
|
||||
*
|
||||
* Renders a 3D signal field using Three.js Points with custom ShaderMaterial.
|
||||
* Each "splat" is a screen-space disc whose size, color and opacity are driven
|
||||
* by the sensing data:
|
||||
* - Size : signal variance / disruption magnitude
|
||||
* - Color : blue (quiet) -> green (presence) -> red (active motion)
|
||||
* - Opacity: classification confidence
|
||||
*/
|
||||
|
||||
// Use global THREE from CDN (loaded in SensingTab)
|
||||
const getThree = () => window.THREE;
|
||||
|
||||
// ---- Custom Splat Shaders ------------------------------------------------
|
||||
|
||||
const SPLAT_VERTEX = `
|
||||
attribute float splatSize;
|
||||
attribute vec3 splatColor;
|
||||
attribute float splatOpacity;
|
||||
|
||||
varying vec3 vColor;
|
||||
varying float vOpacity;
|
||||
|
||||
void main() {
|
||||
vColor = splatColor;
|
||||
vOpacity = splatOpacity;
|
||||
|
||||
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_PointSize = splatSize * (300.0 / -mvPosition.z);
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
const SPLAT_FRAGMENT = `
|
||||
varying vec3 vColor;
|
||||
varying float vOpacity;
|
||||
|
||||
void main() {
|
||||
// Circular soft-edge disc
|
||||
float dist = length(gl_PointCoord - vec2(0.5));
|
||||
if (dist > 0.5) discard;
|
||||
float alpha = smoothstep(0.5, 0.2, dist) * vOpacity;
|
||||
gl_FragColor = vec4(vColor, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
// ---- Color helpers -------------------------------------------------------
|
||||
|
||||
/** Map a scalar 0-1 to blue -> green -> red gradient */
|
||||
function valueToColor(v) {
|
||||
const clamped = Math.max(0, Math.min(1, v));
|
||||
// blue(0) -> cyan(0.25) -> green(0.5) -> yellow(0.75) -> red(1)
|
||||
let r, g, b;
|
||||
if (clamped < 0.5) {
|
||||
const t = clamped * 2;
|
||||
r = 0;
|
||||
g = t;
|
||||
b = 1 - t;
|
||||
} else {
|
||||
const t = (clamped - 0.5) * 2;
|
||||
r = t;
|
||||
g = 1 - t;
|
||||
b = 0;
|
||||
}
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
// ---- GaussianSplatRenderer -----------------------------------------------
|
||||
|
||||
export class GaussianSplatRenderer {
|
||||
/**
|
||||
* @param {HTMLElement} container - DOM element to attach the renderer to
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.width] - canvas width (default container width)
|
||||
* @param {number} [opts.height] - canvas height (default 500)
|
||||
*/
|
||||
constructor(container, opts = {}) {
|
||||
const THREE = getThree();
|
||||
if (!THREE) throw new Error('Three.js not loaded');
|
||||
|
||||
this.container = container;
|
||||
this.width = opts.width || container.clientWidth || 800;
|
||||
this.height = opts.height || 500;
|
||||
|
||||
// Scene
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x0a0a12);
|
||||
|
||||
// Camera — perspective looking down at the room
|
||||
this.camera = new THREE.PerspectiveCamera(55, this.width / this.height, 0.1, 200);
|
||||
this.camera.position.set(0, 14, 14);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
|
||||
// Renderer
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
this.renderer.setSize(this.width, this.height);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
container.appendChild(this.renderer.domElement);
|
||||
|
||||
// Grid & room
|
||||
this._createRoom(THREE);
|
||||
|
||||
// Signal field splats (20x20 = 400 points on the floor plane)
|
||||
this.gridSize = 20;
|
||||
this._createFieldSplats(THREE);
|
||||
|
||||
// Node markers (ESP32 / router positions)
|
||||
this._createNodeMarkers(THREE);
|
||||
|
||||
// Body disruption blob
|
||||
this._createBodyBlob(THREE);
|
||||
|
||||
// Simple orbit-like mouse rotation
|
||||
this._setupMouseControls();
|
||||
|
||||
// Animation state
|
||||
this._animFrame = null;
|
||||
this._lastData = null;
|
||||
|
||||
// Start render loop
|
||||
this._animate();
|
||||
}
|
||||
|
||||
// ---- Scene setup -------------------------------------------------------
|
||||
|
||||
_createRoom(THREE) {
|
||||
// Floor grid
|
||||
const grid = new THREE.GridHelper(20, 20, 0x1a3a4a, 0x0d1f28);
|
||||
this.scene.add(grid);
|
||||
|
||||
// Room boundary wireframe
|
||||
const boxGeo = new THREE.BoxGeometry(20, 6, 20);
|
||||
const edges = new THREE.EdgesGeometry(boxGeo);
|
||||
const line = new THREE.LineSegments(
|
||||
edges,
|
||||
new THREE.LineBasicMaterial({ color: 0x1a4a5a, opacity: 0.3, transparent: true })
|
||||
);
|
||||
line.position.y = 3;
|
||||
this.scene.add(line);
|
||||
}
|
||||
|
||||
_createFieldSplats(THREE) {
|
||||
const count = this.gridSize * this.gridSize;
|
||||
|
||||
const positions = new Float32Array(count * 3);
|
||||
const sizes = new Float32Array(count);
|
||||
const colors = new Float32Array(count * 3);
|
||||
const opacities = new Float32Array(count);
|
||||
|
||||
// Lay splats on the floor plane (y = 0.05 to sit just above grid)
|
||||
for (let iz = 0; iz < this.gridSize; iz++) {
|
||||
for (let ix = 0; ix < this.gridSize; ix++) {
|
||||
const idx = iz * this.gridSize + ix;
|
||||
positions[idx * 3 + 0] = (ix - this.gridSize / 2) + 0.5; // x
|
||||
positions[idx * 3 + 1] = 0.05; // y
|
||||
positions[idx * 3 + 2] = (iz - this.gridSize / 2) + 0.5; // z
|
||||
|
||||
sizes[idx] = 1.5;
|
||||
colors[idx * 3] = 0.1;
|
||||
colors[idx * 3 + 1] = 0.2;
|
||||
colors[idx * 3 + 2] = 0.6;
|
||||
opacities[idx] = 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('splatSize', new THREE.BufferAttribute(sizes, 1));
|
||||
geo.setAttribute('splatColor', new THREE.BufferAttribute(colors, 3));
|
||||
geo.setAttribute('splatOpacity',new THREE.BufferAttribute(opacities, 1));
|
||||
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: SPLAT_VERTEX,
|
||||
fragmentShader: SPLAT_FRAGMENT,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
|
||||
this.fieldPoints = new THREE.Points(geo, mat);
|
||||
this.scene.add(this.fieldPoints);
|
||||
}
|
||||
|
||||
_createNodeMarkers(THREE) {
|
||||
// Router at center — green sphere
|
||||
const routerGeo = new THREE.SphereGeometry(0.3, 16, 16);
|
||||
const routerMat = new THREE.MeshBasicMaterial({ color: 0x00ff88, transparent: true, opacity: 0.8 });
|
||||
this.routerMarker = new THREE.Mesh(routerGeo, routerMat);
|
||||
this.routerMarker.position.set(0, 0.5, 0);
|
||||
this.scene.add(this.routerMarker);
|
||||
|
||||
// ESP32 node — cyan sphere (default position, updated from data)
|
||||
const nodeGeo = new THREE.SphereGeometry(0.25, 16, 16);
|
||||
const nodeMat = new THREE.MeshBasicMaterial({ color: 0x00ccff, transparent: true, opacity: 0.8 });
|
||||
this.nodeMarker = new THREE.Mesh(nodeGeo, nodeMat);
|
||||
this.nodeMarker.position.set(2, 0.5, 1.5);
|
||||
this.scene.add(this.nodeMarker);
|
||||
}
|
||||
|
||||
_createBodyBlob(THREE) {
|
||||
// A cluster of splats representing body disruption
|
||||
const count = 64;
|
||||
const positions = new Float32Array(count * 3);
|
||||
const sizes = new Float32Array(count);
|
||||
const colors = new Float32Array(count * 3);
|
||||
const opacities = new Float32Array(count);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Random sphere distribution
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
const r = Math.random() * 1.5;
|
||||
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||
positions[i * 3 + 1] = r * Math.cos(phi) + 2;
|
||||
positions[i * 3 + 2] = r * Math.sin(phi) * Math.sin(theta);
|
||||
|
||||
sizes[i] = 2 + Math.random() * 3;
|
||||
colors[i * 3] = 0.2;
|
||||
colors[i * 3 + 1] = 0.8;
|
||||
colors[i * 3 + 2] = 0.3;
|
||||
opacities[i] = 0.0; // hidden until presence detected
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('splatSize', new THREE.BufferAttribute(sizes, 1));
|
||||
geo.setAttribute('splatColor', new THREE.BufferAttribute(colors, 3));
|
||||
geo.setAttribute('splatOpacity',new THREE.BufferAttribute(opacities, 1));
|
||||
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: SPLAT_VERTEX,
|
||||
fragmentShader: SPLAT_FRAGMENT,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
|
||||
this.bodyBlob = new THREE.Points(geo, mat);
|
||||
this.scene.add(this.bodyBlob);
|
||||
}
|
||||
|
||||
// ---- Mouse controls (simple orbit) -------------------------------------
|
||||
|
||||
_setupMouseControls() {
|
||||
let isDragging = false;
|
||||
let prevX = 0, prevY = 0;
|
||||
let azimuth = 0, elevation = 55;
|
||||
const radius = 20;
|
||||
|
||||
const updateCamera = () => {
|
||||
const phi = (elevation * Math.PI) / 180;
|
||||
const theta = (azimuth * Math.PI) / 180;
|
||||
this.camera.position.set(
|
||||
radius * Math.sin(phi) * Math.sin(theta),
|
||||
radius * Math.cos(phi),
|
||||
radius * Math.sin(phi) * Math.cos(theta)
|
||||
);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
};
|
||||
|
||||
const canvas = this.renderer.domElement;
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
isDragging = true;
|
||||
prevX = e.clientX;
|
||||
prevY = e.clientY;
|
||||
});
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging) return;
|
||||
azimuth += (e.clientX - prevX) * 0.4;
|
||||
elevation -= (e.clientY - prevY) * 0.4;
|
||||
elevation = Math.max(15, Math.min(85, elevation));
|
||||
prevX = e.clientX;
|
||||
prevY = e.clientY;
|
||||
updateCamera();
|
||||
});
|
||||
canvas.addEventListener('mouseup', () => { isDragging = false; });
|
||||
canvas.addEventListener('mouseleave',() => { isDragging = false; });
|
||||
|
||||
// Scroll to zoom
|
||||
canvas.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 1.05 : 0.95;
|
||||
this.camera.position.multiplyScalar(delta);
|
||||
this.camera.position.clampLength(8, 40);
|
||||
}, { passive: false });
|
||||
|
||||
updateCamera();
|
||||
}
|
||||
|
||||
// ---- Data update -------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update the visualization with new sensing data.
|
||||
* @param {object} data - sensing_update JSON from ws_server
|
||||
*/
|
||||
update(data) {
|
||||
this._lastData = data;
|
||||
if (!data) return;
|
||||
|
||||
const features = data.features || {};
|
||||
const classification = data.classification || {};
|
||||
const signalField = data.signal_field || {};
|
||||
const nodes = data.nodes || [];
|
||||
|
||||
// -- Update signal field splats ----------------------------------------
|
||||
if (signalField.values && this.fieldPoints) {
|
||||
const geo = this.fieldPoints.geometry;
|
||||
const clr = geo.attributes.splatColor.array;
|
||||
const sizes = geo.attributes.splatSize.array;
|
||||
const opac = geo.attributes.splatOpacity.array;
|
||||
const vals = signalField.values;
|
||||
const count = Math.min(vals.length, this.gridSize * this.gridSize);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const v = vals[i];
|
||||
const [r, g, b] = valueToColor(v);
|
||||
clr[i * 3] = r;
|
||||
clr[i * 3 + 1] = g;
|
||||
clr[i * 3 + 2] = b;
|
||||
sizes[i] = 1.0 + v * 4.0;
|
||||
opac[i] = 0.1 + v * 0.6;
|
||||
}
|
||||
|
||||
geo.attributes.splatColor.needsUpdate = true;
|
||||
geo.attributes.splatSize.needsUpdate = true;
|
||||
geo.attributes.splatOpacity.needsUpdate = true;
|
||||
}
|
||||
|
||||
// -- Update body blob --------------------------------------------------
|
||||
if (this.bodyBlob) {
|
||||
const bGeo = this.bodyBlob.geometry;
|
||||
const bOpac = bGeo.attributes.splatOpacity.array;
|
||||
const bClr = bGeo.attributes.splatColor.array;
|
||||
const bSize = bGeo.attributes.splatSize.array;
|
||||
const bPos = bGeo.attributes.position.array;
|
||||
|
||||
const presence = classification.presence || false;
|
||||
const motionLvl = classification.motion_level || 'absent';
|
||||
const confidence = classification.confidence || 0;
|
||||
const breathing = features.breathing_band_power || 0;
|
||||
|
||||
// Breathing pulsation
|
||||
const breathPulse = 1.0 + Math.sin(Date.now() * 0.004) * Math.min(breathing * 3, 0.4);
|
||||
|
||||
for (let i = 0; i < bOpac.length; i++) {
|
||||
if (presence) {
|
||||
bOpac[i] = confidence * 0.4;
|
||||
|
||||
// Color by motion level
|
||||
if (motionLvl === 'active') {
|
||||
bClr[i * 3] = 1.0;
|
||||
bClr[i * 3 + 1] = 0.2;
|
||||
bClr[i * 3 + 2] = 0.1;
|
||||
} else {
|
||||
bClr[i * 3] = 0.1;
|
||||
bClr[i * 3 + 1] = 0.8;
|
||||
bClr[i * 3 + 2] = 0.4;
|
||||
}
|
||||
|
||||
bSize[i] = (2 + Math.random() * 2) * breathPulse;
|
||||
} else {
|
||||
bOpac[i] = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
bGeo.attributes.splatOpacity.needsUpdate = true;
|
||||
bGeo.attributes.splatColor.needsUpdate = true;
|
||||
bGeo.attributes.splatSize.needsUpdate = true;
|
||||
}
|
||||
|
||||
// -- Update node positions ---------------------------------------------
|
||||
if (nodes.length > 0 && nodes[0].position) {
|
||||
const pos = nodes[0].position;
|
||||
this.nodeMarker.position.set(pos[0], 0.5, pos[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Render loop -------------------------------------------------------
|
||||
|
||||
_animate() {
|
||||
this._animFrame = requestAnimationFrame(() => this._animate());
|
||||
|
||||
// Gentle router glow pulse
|
||||
if (this.routerMarker) {
|
||||
const pulse = 0.6 + 0.3 * Math.sin(Date.now() * 0.003);
|
||||
this.routerMarker.material.opacity = pulse;
|
||||
}
|
||||
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
// ---- Resize / cleanup --------------------------------------------------
|
||||
|
||||
resize(width, height) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.camera.aspect = width / height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(width, height);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._animFrame) {
|
||||
cancelAnimationFrame(this._animFrame);
|
||||
}
|
||||
this.renderer.dispose();
|
||||
if (this.renderer.domElement.parentNode) {
|
||||
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user