diff --git a/README.md b/README.md
index a231791..474bc3e 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,17 @@
# WiFi DensePose
+> **Hardware Required:** This system processes real WiFi Channel State Information (CSI) data. To capture live CSI you need one of:
+>
+> | Option | Hardware | Cost | Capabilities |
+> |--------|----------|------|-------------|
+> | **ESP32 Mesh** (recommended) | 3-6x ESP32-S3 boards + consumer WiFi router | ~$54 | Presence, motion, respiration detection |
+> | **Research NIC** | Intel 5300 or Atheros AR9580 (discontinued) | ~$50-100 | Full CSI with 3x3 MIMO |
+> | **Commodity WiFi** | Any Linux laptop with WiFi | $0 | Presence and coarse motion only (RSSI-based) |
+>
+> Without CSI-capable hardware, you can verify the signal processing pipeline using the included deterministic reference signal: `python v1/data/proof/verify.py`
+>
+> See [docs/adr/ADR-012-esp32-csi-sensor-mesh.md](docs/adr/ADR-012-esp32-csi-sensor-mesh.md) for the ESP32 setup guide and [docs/adr/ADR-013-feature-level-sensing-commodity-gear.md](docs/adr/ADR-013-feature-level-sensing-commodity-gear.md) for the zero-cost RSSI path.
+
[](https://www.python.org/downloads/)
[](https://fastapi.tiangolo.com/)
[](https://opensource.org/licenses/MIT)
diff --git a/ui/components/dashboard-hud.js b/ui/components/dashboard-hud.js
new file mode 100644
index 0000000..8349a37
--- /dev/null
+++ b/ui/components/dashboard-hud.js
@@ -0,0 +1,429 @@
+// Dashboard HUD Overlay - WiFi DensePose 3D Visualization
+// Connection status, FPS counter, detection confidence, person count, sensing mode
+
+export class DashboardHUD {
+ constructor(container) {
+ this.container = typeof container === 'string'
+ ? document.getElementById(container)
+ : container;
+
+ // State
+ this.state = {
+ connectionStatus: 'disconnected', // connected, disconnected, connecting, error
+ isRealData: false,
+ fps: 0,
+ confidence: 0,
+ personCount: 0,
+ sensingMode: 'Mock', // CSI, RSSI, Mock
+ latency: 0,
+ messageCount: 0,
+ uptime: 0
+ };
+
+ this._fpsFrames = [];
+ this._lastFpsUpdate = 0;
+
+ this._build();
+ }
+
+ _build() {
+ // Create HUD overlay container
+ this.hudElement = document.createElement('div');
+ this.hudElement.id = 'viz-hud';
+ this.hudElement.innerHTML = `
+
+
+
+
MOCK DATA
+
+
+
+
+
+
+
+
+
+
+
+ Disconnected
+
+
+ Latency
+ -- ms
+
+
+ Messages
+ 0
+
+
+ Uptime
+ 0s
+
+
+
+
+
+
-- FPS
+
+ Frame
+ -- ms
+
+
+
+
+
+
+ Persons
+ 0
+
+
+ Confidence
+ 0%
+
+
+
+
+
+
+
MOCK
+
+ WiFi DensePose
+
+
+
+
+
+ Drag to orbit | Scroll to zoom | Right-click to pan
+
+ `;
+
+ this.container.style.position = 'relative';
+ this.container.appendChild(this.hudElement);
+
+ // Cache DOM references
+ this._els = {
+ banner: this.hudElement.querySelector('#hud-banner'),
+ statusDot: this.hudElement.querySelector('#hud-status-dot'),
+ connStatus: this.hudElement.querySelector('#hud-conn-status'),
+ latency: this.hudElement.querySelector('#hud-latency'),
+ msgCount: this.hudElement.querySelector('#hud-msg-count'),
+ uptime: this.hudElement.querySelector('#hud-uptime'),
+ fps: this.hudElement.querySelector('#hud-fps'),
+ frameTime: this.hudElement.querySelector('#hud-frame-time'),
+ personCount: this.hudElement.querySelector('#hud-person-count'),
+ confidence: this.hudElement.querySelector('#hud-confidence'),
+ confidenceFill: this.hudElement.querySelector('#hud-confidence-fill'),
+ modeBadge: this.hudElement.querySelector('#hud-mode-badge')
+ };
+ }
+
+ // Update state from external data
+ updateState(newState) {
+ Object.assign(this.state, newState);
+ this._render();
+ }
+
+ // Track FPS - call each frame
+ tickFPS() {
+ const now = performance.now();
+ this._fpsFrames.push(now);
+
+ // Keep only last second of frames
+ while (this._fpsFrames.length > 0 && this._fpsFrames[0] < now - 1000) {
+ this._fpsFrames.shift();
+ }
+
+ // Update FPS display at most 4 times per second
+ if (now - this._lastFpsUpdate > 250) {
+ this.state.fps = this._fpsFrames.length;
+ const frameTime = this._fpsFrames.length > 1
+ ? (now - this._fpsFrames[0]) / (this._fpsFrames.length - 1)
+ : 0;
+ this._lastFpsUpdate = now;
+
+ // Update FPS elements
+ this._els.fps.textContent = `${this.state.fps} FPS`;
+ this._els.fps.className = 'hud-fps ' + (
+ this.state.fps >= 50 ? 'high' : this.state.fps >= 25 ? 'mid' : 'low'
+ );
+ this._els.frameTime.textContent = `${frameTime.toFixed(1)} ms`;
+ }
+ }
+
+ _render() {
+ const { state } = this;
+
+ // Banner
+ if (state.isRealData) {
+ this._els.banner.textContent = 'REAL DATA - LIVE STREAM';
+ this._els.banner.className = 'hud-banner real';
+ } else {
+ this._els.banner.textContent = 'MOCK DATA - DEMO MODE';
+ this._els.banner.className = 'hud-banner mock';
+ }
+
+ // Connection status
+ this._els.statusDot.className = `hud-status-dot ${state.connectionStatus}`;
+ const statusText = {
+ connected: 'Connected',
+ disconnected: 'Disconnected',
+ connecting: 'Connecting...',
+ error: 'Error'
+ };
+ this._els.connStatus.textContent = statusText[state.connectionStatus] || 'Unknown';
+
+ // Latency
+ this._els.latency.textContent = state.latency > 0 ? `${state.latency.toFixed(0)} ms` : '-- ms';
+
+ // Messages
+ this._els.msgCount.textContent = state.messageCount.toLocaleString();
+
+ // Uptime
+ const uptimeSec = Math.floor(state.uptime);
+ if (uptimeSec < 60) {
+ this._els.uptime.textContent = `${uptimeSec}s`;
+ } else if (uptimeSec < 3600) {
+ this._els.uptime.textContent = `${Math.floor(uptimeSec / 60)}m ${uptimeSec % 60}s`;
+ } else {
+ const h = Math.floor(uptimeSec / 3600);
+ const m = Math.floor((uptimeSec % 3600) / 60);
+ this._els.uptime.textContent = `${h}h ${m}m`;
+ }
+
+ // Person count
+ this._els.personCount.textContent = state.personCount;
+ this._els.personCount.style.color = state.personCount > 0 ? '#00ff88' : '#556677';
+
+ // Confidence
+ const confPct = (state.confidence * 100).toFixed(1);
+ this._els.confidence.textContent = `${confPct}%`;
+ this._els.confidenceFill.style.width = `${state.confidence * 100}%`;
+ // Color temperature: red (low) -> yellow (mid) -> green (high)
+ const confHue = state.confidence * 120; // 0=red, 60=yellow, 120=green
+ this._els.confidenceFill.style.background = `hsl(${confHue}, 100%, 45%)`;
+
+ // Sensing mode
+ const modeLower = (state.sensingMode || 'Mock').toLowerCase();
+ this._els.modeBadge.textContent = state.sensingMode.toUpperCase();
+ this._els.modeBadge.className = `hud-mode-badge ${modeLower}`;
+ }
+
+ dispose() {
+ if (this.hudElement && this.hudElement.parentNode) {
+ this.hudElement.parentNode.removeChild(this.hudElement);
+ }
+ }
+}
diff --git a/ui/components/environment.js b/ui/components/environment.js
new file mode 100644
index 0000000..116b218
--- /dev/null
+++ b/ui/components/environment.js
@@ -0,0 +1,476 @@
+// Room Environment - WiFi DensePose 3D Visualization
+// Grid floor, AP/receiver markers, detection zones, confidence heatmap
+
+export class Environment {
+ constructor(scene) {
+ this.scene = scene;
+ this.group = new THREE.Group();
+ this.group.name = 'environment';
+
+ // Room dimensions (meters)
+ this.roomWidth = 8;
+ this.roomDepth = 6;
+ this.roomHeight = 3;
+
+ // AP and receiver positions
+ this.accessPoints = [
+ { id: 'TX1', pos: [-3.5, 2.5, -2.8], type: 'transmitter' },
+ { id: 'TX2', pos: [0, 2.5, -2.8], type: 'transmitter' },
+ { id: 'TX3', pos: [3.5, 2.5, -2.8], type: 'transmitter' }
+ ];
+ this.receivers = [
+ { id: 'RX1', pos: [-3.5, 2.5, 2.8], type: 'receiver' },
+ { id: 'RX2', pos: [0, 2.5, 2.8], type: 'receiver' },
+ { id: 'RX3', pos: [3.5, 2.5, 2.8], type: 'receiver' }
+ ];
+
+ // Detection zones
+ this.zones = [
+ { id: 'zone_1', center: [-2, 0, 0], radius: 2, color: 0x0066ff, label: 'Zone 1' },
+ { id: 'zone_2', center: [0, 0, 0], radius: 2, color: 0x00cc66, label: 'Zone 2' },
+ { id: 'zone_3', center: [2, 0, 0], radius: 2, color: 0xff6600, label: 'Zone 3' }
+ ];
+
+ // Confidence heatmap state
+ this._heatmapData = new Float32Array(20 * 15); // 20x15 grid
+ this._heatmapCells = [];
+
+ // Build everything
+ this._buildFloor();
+ this._buildGrid();
+ this._buildWalls();
+ this._buildAPMarkers();
+ this._buildSignalPaths();
+ this._buildDetectionZones();
+ this._buildConfidenceHeatmap();
+
+ this.scene.add(this.group);
+ }
+
+ _buildFloor() {
+ // Dark reflective floor
+ const floorGeom = new THREE.PlaneGeometry(this.roomWidth, this.roomDepth);
+ const floorMat = new THREE.MeshPhongMaterial({
+ color: 0x0a0a15,
+ emissive: 0x050510,
+ shininess: 60,
+ specular: 0x111122,
+ transparent: true,
+ opacity: 0.95,
+ side: THREE.DoubleSide
+ });
+ const floor = new THREE.Mesh(floorGeom, floorMat);
+ floor.rotation.x = -Math.PI / 2;
+ floor.position.y = 0;
+ floor.receiveShadow = true;
+ this.group.add(floor);
+ }
+
+ _buildGrid() {
+ // Grid lines on the floor
+ const gridGroup = new THREE.Group();
+ const gridMat = new THREE.LineBasicMaterial({
+ color: 0x1a1a3a,
+ transparent: true,
+ opacity: 0.4
+ });
+
+ const halfW = this.roomWidth / 2;
+ const halfD = this.roomDepth / 2;
+ const step = 0.5;
+
+ // Lines along X
+ for (let z = -halfD; z <= halfD; z += step) {
+ const geom = new THREE.BufferGeometry();
+ const positions = new Float32Array([-halfW, 0.005, z, halfW, 0.005, z]);
+ geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+ gridGroup.add(new THREE.Line(geom, gridMat));
+ }
+
+ // Lines along Z
+ for (let x = -halfW; x <= halfW; x += step) {
+ const geom = new THREE.BufferGeometry();
+ const positions = new Float32Array([x, 0.005, -halfD, x, 0.005, halfD]);
+ geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+ gridGroup.add(new THREE.Line(geom, gridMat));
+ }
+
+ // Brighter center lines
+ const centerMat = new THREE.LineBasicMaterial({
+ color: 0x2233aa,
+ transparent: true,
+ opacity: 0.25
+ });
+ const centerX = new THREE.BufferGeometry();
+ centerX.setAttribute('position', new THREE.BufferAttribute(
+ new Float32Array([-halfW, 0.006, 0, halfW, 0.006, 0]), 3));
+ gridGroup.add(new THREE.Line(centerX, centerMat));
+
+ const centerZ = new THREE.BufferGeometry();
+ centerZ.setAttribute('position', new THREE.BufferAttribute(
+ new Float32Array([0, 0.006, -halfD, 0, 0.006, halfD]), 3));
+ gridGroup.add(new THREE.Line(centerZ, centerMat));
+
+ this.group.add(gridGroup);
+ }
+
+ _buildWalls() {
+ // Subtle transparent walls to define the room boundary
+ const wallMat = new THREE.MeshBasicMaterial({
+ color: 0x112244,
+ transparent: true,
+ opacity: 0.06,
+ side: THREE.DoubleSide,
+ depthWrite: false
+ });
+
+ const halfW = this.roomWidth / 2;
+ const halfD = this.roomDepth / 2;
+ const h = this.roomHeight;
+
+ // Back wall
+ const backWall = new THREE.Mesh(new THREE.PlaneGeometry(this.roomWidth, h), wallMat);
+ backWall.position.set(0, h / 2, -halfD);
+ this.group.add(backWall);
+
+ // Front wall (more transparent)
+ const frontMat = wallMat.clone();
+ frontMat.opacity = 0.03;
+ const frontWall = new THREE.Mesh(new THREE.PlaneGeometry(this.roomWidth, h), frontMat);
+ frontWall.position.set(0, h / 2, halfD);
+ this.group.add(frontWall);
+
+ // Side walls
+ const leftWall = new THREE.Mesh(new THREE.PlaneGeometry(this.roomDepth, h), wallMat);
+ leftWall.rotation.y = Math.PI / 2;
+ leftWall.position.set(-halfW, h / 2, 0);
+ this.group.add(leftWall);
+
+ const rightWall = new THREE.Mesh(new THREE.PlaneGeometry(this.roomDepth, h), wallMat);
+ rightWall.rotation.y = -Math.PI / 2;
+ rightWall.position.set(halfW, h / 2, 0);
+ this.group.add(rightWall);
+
+ // Wall edge lines
+ const edgeMat = new THREE.LineBasicMaterial({
+ color: 0x334466,
+ transparent: true,
+ opacity: 0.3
+ });
+ const edges = [
+ // Floor edges
+ [-halfW, 0, -halfD, halfW, 0, -halfD],
+ [halfW, 0, -halfD, halfW, 0, halfD],
+ [halfW, 0, halfD, -halfW, 0, halfD],
+ [-halfW, 0, halfD, -halfW, 0, -halfD],
+ // Ceiling edges
+ [-halfW, h, -halfD, halfW, h, -halfD],
+ [halfW, h, -halfD, halfW, h, halfD],
+ [-halfW, h, halfD, -halfW, h, -halfD],
+ // Vertical edges
+ [-halfW, 0, -halfD, -halfW, h, -halfD],
+ [halfW, 0, -halfD, halfW, h, -halfD],
+ [-halfW, 0, halfD, -halfW, h, halfD],
+ [halfW, 0, halfD, halfW, h, halfD]
+ ];
+
+ for (const e of edges) {
+ const geom = new THREE.BufferGeometry();
+ geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(e), 3));
+ this.group.add(new THREE.Line(geom, edgeMat));
+ }
+ }
+
+ _buildAPMarkers() {
+ this._apMeshes = [];
+ this._rxMeshes = [];
+
+ // Transmitter markers: small pyramid/cone shape, blue
+ const txGeom = new THREE.ConeGeometry(0.12, 0.25, 4);
+ const txMat = new THREE.MeshPhongMaterial({
+ color: 0x0088ff,
+ emissive: 0x003366,
+ emissiveIntensity: 0.5,
+ transparent: true,
+ opacity: 0.9
+ });
+
+ for (const ap of this.accessPoints) {
+ const mesh = new THREE.Mesh(txGeom, txMat.clone());
+ mesh.position.set(...ap.pos);
+ mesh.rotation.z = Math.PI; // Point downward
+ mesh.castShadow = true;
+ mesh.name = `ap-${ap.id}`;
+ this.group.add(mesh);
+ this._apMeshes.push(mesh);
+
+ // Small point light at each AP
+ const light = new THREE.PointLight(0x0066ff, 0.3, 4);
+ light.position.set(...ap.pos);
+ this.group.add(light);
+
+ // Label
+ const label = this._createLabel(ap.id, 0x0088ff);
+ label.position.set(ap.pos[0], ap.pos[1] + 0.3, ap.pos[2]);
+ this.group.add(label);
+ }
+
+ // Receiver markers: inverted cone, green
+ const rxGeom = new THREE.ConeGeometry(0.12, 0.25, 4);
+ const rxMat = new THREE.MeshPhongMaterial({
+ color: 0x00cc44,
+ emissive: 0x004422,
+ emissiveIntensity: 0.5,
+ transparent: true,
+ opacity: 0.9
+ });
+
+ for (const rx of this.receivers) {
+ const mesh = new THREE.Mesh(rxGeom, rxMat.clone());
+ mesh.position.set(...rx.pos);
+ mesh.castShadow = true;
+ mesh.name = `rx-${rx.id}`;
+ this.group.add(mesh);
+ this._rxMeshes.push(mesh);
+
+ // Small point light
+ const light = new THREE.PointLight(0x00cc44, 0.2, 3);
+ light.position.set(...rx.pos);
+ this.group.add(light);
+
+ // Label
+ const label = this._createLabel(rx.id, 0x00cc44);
+ label.position.set(rx.pos[0], rx.pos[1] + 0.3, rx.pos[2]);
+ this.group.add(label);
+ }
+ }
+
+ _buildSignalPaths() {
+ // Dashed lines from each TX to each RX showing WiFi signal paths
+ this._signalLines = [];
+ const lineMat = new THREE.LineDashedMaterial({
+ color: 0x1133aa,
+ transparent: true,
+ opacity: 0.15,
+ dashSize: 0.15,
+ gapSize: 0.1,
+ linewidth: 1
+ });
+
+ for (const tx of this.accessPoints) {
+ for (const rx of this.receivers) {
+ const geom = new THREE.BufferGeometry();
+ const positions = new Float32Array([...tx.pos, ...rx.pos]);
+ geom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+ const line = new THREE.Line(geom, lineMat.clone());
+ line.computeLineDistances();
+ this.group.add(line);
+ this._signalLines.push(line);
+ }
+ }
+ }
+
+ _buildDetectionZones() {
+ this._zoneMeshes = {};
+
+ for (const zone of this.zones) {
+ const zoneGroup = new THREE.Group();
+ zoneGroup.name = `zone-${zone.id}`;
+
+ // Zone circle on floor
+ const circleGeom = new THREE.RingGeometry(zone.radius * 0.95, zone.radius, 48);
+ const circleMat = new THREE.MeshBasicMaterial({
+ color: zone.color,
+ transparent: true,
+ opacity: 0.12,
+ side: THREE.DoubleSide,
+ depthWrite: false
+ });
+ const circle = new THREE.Mesh(circleGeom, circleMat);
+ circle.rotation.x = -Math.PI / 2;
+ circle.position.set(zone.center[0], 0.01, zone.center[2]);
+ zoneGroup.add(circle);
+
+ // Zone fill
+ const fillGeom = new THREE.CircleGeometry(zone.radius * 0.95, 48);
+ const fillMat = new THREE.MeshBasicMaterial({
+ color: zone.color,
+ transparent: true,
+ opacity: 0.04,
+ side: THREE.DoubleSide,
+ depthWrite: false
+ });
+ const fill = new THREE.Mesh(fillGeom, fillMat);
+ fill.rotation.x = -Math.PI / 2;
+ fill.position.set(zone.center[0], 0.008, zone.center[2]);
+ zoneGroup.add(fill);
+
+ // Zone label
+ const label = this._createLabel(zone.label, zone.color);
+ label.position.set(zone.center[0], 0.15, zone.center[2] + zone.radius + 0.2);
+ label.scale.set(1.0, 0.25, 1);
+ zoneGroup.add(label);
+
+ this.group.add(zoneGroup);
+ this._zoneMeshes[zone.id] = { group: zoneGroup, circle, fill, circleMat, fillMat };
+ }
+ }
+
+ _buildConfidenceHeatmap() {
+ // Ground-level heatmap showing detection confidence across the room
+ const cols = 20;
+ const rows = 15;
+ const cellW = this.roomWidth / cols;
+ const cellD = this.roomDepth / rows;
+ const cellGeom = new THREE.PlaneGeometry(cellW * 0.95, cellD * 0.95);
+
+ this._heatmapGroup = new THREE.Group();
+ this._heatmapGroup.position.y = 0.003;
+
+ for (let r = 0; r < rows; r++) {
+ const rowCells = [];
+ for (let c = 0; c < cols; c++) {
+ const mat = new THREE.MeshBasicMaterial({
+ color: 0x000000,
+ transparent: true,
+ opacity: 0,
+ side: THREE.DoubleSide,
+ depthWrite: false
+ });
+ const cell = new THREE.Mesh(cellGeom, mat);
+ cell.rotation.x = -Math.PI / 2;
+ cell.position.set(
+ (c + 0.5) * cellW - this.roomWidth / 2,
+ 0,
+ (r + 0.5) * cellD - this.roomDepth / 2
+ );
+ this._heatmapGroup.add(cell);
+ rowCells.push(cell);
+ }
+ this._heatmapCells.push(rowCells);
+ }
+
+ this.group.add(this._heatmapGroup);
+ }
+
+ _createLabel(text, color) {
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ canvas.width = 128;
+ canvas.height = 32;
+
+ ctx.font = 'bold 14px monospace';
+ ctx.fillStyle = '#' + new THREE.Color(color).getHexString();
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(text, canvas.width / 2, canvas.height / 2);
+
+ const texture = new THREE.CanvasTexture(canvas);
+ const mat = new THREE.SpriteMaterial({
+ map: texture,
+ transparent: true,
+ depthWrite: false
+ });
+ return new THREE.Sprite(mat);
+ }
+
+ // Update zone occupancy display
+ // zoneOccupancy: { zone_1: count, zone_2: count, ... }
+ updateZoneOccupancy(zoneOccupancy) {
+ if (!zoneOccupancy) return;
+
+ for (const [zoneId, meshes] of Object.entries(this._zoneMeshes)) {
+ const count = zoneOccupancy[zoneId] || 0;
+ const isOccupied = count > 0;
+
+ // Brighten occupied zones
+ meshes.circleMat.opacity = isOccupied ? 0.25 : 0.08;
+ meshes.fillMat.opacity = isOccupied ? 0.10 : 0.03;
+ }
+ }
+
+ // Update confidence heatmap from detection data
+ // confidenceMap: 2D array or flat array of confidence values [0,1]
+ updateConfidenceHeatmap(confidenceMap) {
+ if (!confidenceMap) return;
+ const rows = this._heatmapCells.length;
+ const cols = this._heatmapCells[0]?.length || 0;
+
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ const idx = r * cols + c;
+ const val = Array.isArray(confidenceMap)
+ ? (Array.isArray(confidenceMap[r]) ? confidenceMap[r][c] : confidenceMap[idx])
+ : (confidenceMap[idx] || 0);
+
+ const cell = this._heatmapCells[r][c];
+ if (val > 0.01) {
+ // Color temperature: blue (low) -> green (mid) -> red (high)
+ cell.material.color.setHSL(0.6 - val * 0.6, 1.0, 0.3 + val * 0.3);
+ cell.material.opacity = val * 0.3;
+ } else {
+ cell.material.opacity = 0;
+ }
+ }
+ }
+ }
+
+ // Generate a demo confidence heatmap centered on given positions
+ static generateDemoHeatmap(personPositions, cols, rows, roomWidth, roomDepth) {
+ const map = new Float32Array(cols * rows);
+ const cellW = roomWidth / cols;
+ const cellD = roomDepth / rows;
+
+ for (const pos of (personPositions || [])) {
+ for (let r = 0; r < rows; r++) {
+ for (let c = 0; c < cols; c++) {
+ const cx = (c + 0.5) * cellW - roomWidth / 2;
+ const cz = (r + 0.5) * cellD - roomDepth / 2;
+ const dx = cx - (pos.x || 0);
+ const dz = cz - (pos.z || 0);
+ const dist = Math.sqrt(dx * dx + dz * dz);
+ const conf = Math.exp(-dist * dist * 0.5) * (pos.confidence || 0.8);
+ map[r * cols + c] = Math.max(map[r * cols + c], conf);
+ }
+ }
+ }
+ return map;
+ }
+
+ // Animate AP and RX markers (subtle pulse)
+ update(delta, elapsed) {
+ // Pulse AP markers
+ for (const mesh of this._apMeshes) {
+ const pulse = 0.9 + Math.sin(elapsed * 2) * 0.1;
+ mesh.scale.setScalar(pulse);
+ mesh.material.emissiveIntensity = 0.3 + Math.sin(elapsed * 3) * 0.15;
+ }
+
+ // Pulse RX markers
+ for (const mesh of this._rxMeshes) {
+ const pulse = 0.9 + Math.sin(elapsed * 2 + Math.PI) * 0.1;
+ mesh.scale.setScalar(pulse);
+ mesh.material.emissiveIntensity = 0.3 + Math.sin(elapsed * 3 + Math.PI) * 0.15;
+ }
+
+ // Animate signal paths subtly
+ for (const line of this._signalLines) {
+ line.material.opacity = 0.08 + Math.sin(elapsed * 1.5) * 0.05;
+ }
+ }
+
+ getGroup() {
+ return this.group;
+ }
+
+ dispose() {
+ this.group.traverse((child) => {
+ if (child.geometry) child.geometry.dispose();
+ if (child.material) {
+ if (child.material.map) child.material.map.dispose();
+ child.material.dispose();
+ }
+ });
+ this.scene.remove(this.group);
+ }
+}
diff --git a/ui/components/signal-viz.js b/ui/components/signal-viz.js
new file mode 100644
index 0000000..af60f68
--- /dev/null
+++ b/ui/components/signal-viz.js
@@ -0,0 +1,467 @@
+// Real-time CSI Signal Visualization - WiFi DensePose
+// Amplitude heatmap, Phase plot, Doppler spectrum, Motion energy
+
+export class SignalVisualization {
+ constructor(scene) {
+ this.scene = scene;
+ this.group = new THREE.Group();
+ this.group.name = 'signal-visualization';
+ this.group.position.set(-5.5, 0, -3);
+
+ // Configuration
+ this.config = {
+ subcarriers: 30,
+ timeSlots: 40,
+ heatmapWidth: 3.0,
+ heatmapHeight: 1.5,
+ phaseWidth: 3.0,
+ phaseHeight: 1.0,
+ dopplerBars: 16,
+ dopplerWidth: 2.0,
+ dopplerHeight: 1.0
+ };
+
+ // Data buffers
+ this.amplitudeHistory = [];
+ this.phaseData = new Float32Array(this.config.subcarriers);
+ this.dopplerData = new Float32Array(this.config.dopplerBars);
+ this.motionEnergy = 0;
+ this.targetMotionEnergy = 0;
+
+ // Initialize for timeSlots rows of subcarrier data
+ for (let i = 0; i < this.config.timeSlots; i++) {
+ this.amplitudeHistory.push(new Float32Array(this.config.subcarriers));
+ }
+
+ // Build visualizations
+ this._buildAmplitudeHeatmap();
+ this._buildPhasePlot();
+ this._buildDopplerSpectrum();
+ this._buildMotionIndicator();
+ this._buildLabels();
+
+ this.scene.add(this.group);
+ }
+
+ _buildAmplitudeHeatmap() {
+ // Create a grid of colored cells for CSI amplitude across subcarriers over time
+ const { subcarriers, timeSlots, heatmapWidth, heatmapHeight } = this.config;
+ const cellW = heatmapWidth / subcarriers;
+ const cellH = heatmapHeight / timeSlots;
+
+ this._heatmapCells = [];
+ this._heatmapGroup = new THREE.Group();
+ this._heatmapGroup.position.set(0, 3.5, 0);
+
+ const cellGeom = new THREE.PlaneGeometry(cellW * 0.9, cellH * 0.9);
+
+ for (let t = 0; t < timeSlots; t++) {
+ const row = [];
+ for (let s = 0; s < subcarriers; s++) {
+ const mat = new THREE.MeshBasicMaterial({
+ color: 0x000022,
+ transparent: true,
+ opacity: 0.85,
+ side: THREE.DoubleSide
+ });
+ const cell = new THREE.Mesh(cellGeom, mat);
+ cell.position.set(
+ s * cellW - heatmapWidth / 2 + cellW / 2,
+ t * cellH,
+ 0
+ );
+ this._heatmapGroup.add(cell);
+ row.push(cell);
+ }
+ this._heatmapCells.push(row);
+ }
+
+ // Border frame
+ const frameGeom = new THREE.EdgesGeometry(
+ new THREE.PlaneGeometry(heatmapWidth + 0.1, heatmapHeight + 0.1)
+ );
+ const frameMat = new THREE.LineBasicMaterial({ color: 0x335577, opacity: 0.5, transparent: true });
+ const frame = new THREE.LineSegments(frameGeom, frameMat);
+ frame.position.set(0, heatmapHeight / 2, -0.01);
+ this._heatmapGroup.add(frame);
+
+ this.group.add(this._heatmapGroup);
+ }
+
+ _buildPhasePlot() {
+ // Line chart showing phase across subcarriers in 3D space
+ const { subcarriers, phaseWidth, phaseHeight } = this.config;
+
+ this._phaseGroup = new THREE.Group();
+ this._phaseGroup.position.set(0, 2.0, 0);
+
+ // Create the phase line
+ const positions = new Float32Array(subcarriers * 3);
+ for (let i = 0; i < subcarriers; i++) {
+ positions[i * 3] = (i / (subcarriers - 1)) * phaseWidth - phaseWidth / 2;
+ positions[i * 3 + 1] = 0;
+ positions[i * 3 + 2] = 0;
+ }
+
+ const phaseGeom = new THREE.BufferGeometry();
+ phaseGeom.setAttribute('position', new THREE.BufferAttribute(positions, 3));
+
+ const phaseMat = new THREE.LineBasicMaterial({
+ color: 0x00ff88,
+ transparent: true,
+ opacity: 0.8,
+ linewidth: 2
+ });
+
+ this._phaseLine = new THREE.Line(phaseGeom, phaseMat);
+ this._phaseGroup.add(this._phaseLine);
+
+ // Phase reference line (zero line)
+ const refPositions = new Float32Array(6);
+ refPositions[0] = -phaseWidth / 2; refPositions[1] = 0; refPositions[2] = 0;
+ refPositions[3] = phaseWidth / 2; refPositions[4] = 0; refPositions[5] = 0;
+ const refGeom = new THREE.BufferGeometry();
+ refGeom.setAttribute('position', new THREE.BufferAttribute(refPositions, 3));
+ const refMat = new THREE.LineBasicMaterial({ color: 0x224433, opacity: 0.3, transparent: true });
+ this._phaseGroup.add(new THREE.LineSegments(refGeom, refMat));
+
+ // Vertical axis lines
+ const axisPositions = new Float32Array(12);
+ // Left axis
+ axisPositions[0] = -phaseWidth / 2; axisPositions[1] = -phaseHeight / 2; axisPositions[2] = 0;
+ axisPositions[3] = -phaseWidth / 2; axisPositions[4] = phaseHeight / 2; axisPositions[5] = 0;
+ // Right axis
+ axisPositions[6] = phaseWidth / 2; axisPositions[7] = -phaseHeight / 2; axisPositions[8] = 0;
+ axisPositions[9] = phaseWidth / 2; axisPositions[10] = phaseHeight / 2; axisPositions[11] = 0;
+ const axisGeom = new THREE.BufferGeometry();
+ axisGeom.setAttribute('position', new THREE.BufferAttribute(axisPositions, 3));
+ this._phaseGroup.add(new THREE.LineSegments(axisGeom, refMat));
+
+ this.group.add(this._phaseGroup);
+ }
+
+ _buildDopplerSpectrum() {
+ // Bar chart for Doppler frequency spectrum
+ const { dopplerBars, dopplerWidth, dopplerHeight } = this.config;
+ const barWidth = (dopplerWidth / dopplerBars) * 0.8;
+ const gap = (dopplerWidth / dopplerBars) * 0.2;
+
+ this._dopplerGroup = new THREE.Group();
+ this._dopplerGroup.position.set(0, 0.8, 0);
+ this._dopplerBars = [];
+
+ const barGeom = new THREE.BoxGeometry(barWidth, 1, 0.05);
+
+ for (let i = 0; i < dopplerBars; i++) {
+ const mat = new THREE.MeshBasicMaterial({
+ color: 0x0044aa,
+ transparent: true,
+ opacity: 0.75
+ });
+ const bar = new THREE.Mesh(barGeom, mat);
+ const x = (i / (dopplerBars - 1)) * dopplerWidth - dopplerWidth / 2;
+ bar.position.set(x, 0, 0);
+ bar.scale.y = 0.01; // Start flat
+ this._dopplerGroup.add(bar);
+ this._dopplerBars.push(bar);
+ }
+
+ // Base line
+ const basePositions = new Float32Array(6);
+ basePositions[0] = -dopplerWidth / 2 - 0.1; basePositions[1] = 0; basePositions[2] = 0;
+ basePositions[3] = dopplerWidth / 2 + 0.1; basePositions[4] = 0; basePositions[5] = 0;
+ const baseGeom = new THREE.BufferGeometry();
+ baseGeom.setAttribute('position', new THREE.BufferAttribute(basePositions, 3));
+ const baseMat = new THREE.LineBasicMaterial({ color: 0x335577, opacity: 0.5, transparent: true });
+ this._dopplerGroup.add(new THREE.LineSegments(baseGeom, baseMat));
+
+ this.group.add(this._dopplerGroup);
+ }
+
+ _buildMotionIndicator() {
+ // Pulsating sphere that grows/brightens with motion energy
+ this._motionGroup = new THREE.Group();
+ this._motionGroup.position.set(2.0, 1.5, 0);
+
+ // Outer glow ring
+ const ringGeom = new THREE.RingGeometry(0.25, 0.3, 32);
+ const ringMat = new THREE.MeshBasicMaterial({
+ color: 0x00ff44,
+ transparent: true,
+ opacity: 0.3,
+ side: THREE.DoubleSide
+ });
+ this._motionRing = new THREE.Mesh(ringGeom, ringMat);
+ this._motionGroup.add(this._motionRing);
+
+ // Inner core
+ const coreGeom = new THREE.SphereGeometry(0.15, 16, 16);
+ const coreMat = new THREE.MeshBasicMaterial({
+ color: 0x004422,
+ transparent: true,
+ opacity: 0.6
+ });
+ this._motionCore = new THREE.Mesh(coreGeom, coreMat);
+ this._motionGroup.add(this._motionCore);
+
+ // Surrounding pulse rings
+ this._pulseRings = [];
+ for (let i = 0; i < 3; i++) {
+ const pulseGeom = new THREE.RingGeometry(0.3, 0.32, 32);
+ const pulseMat = new THREE.MeshBasicMaterial({
+ color: 0x00ff88,
+ transparent: true,
+ opacity: 0,
+ side: THREE.DoubleSide
+ });
+ const ring = new THREE.Mesh(pulseGeom, pulseMat);
+ ring.userData.phase = (i / 3) * Math.PI * 2;
+ this._motionGroup.add(ring);
+ this._pulseRings.push(ring);
+ }
+
+ this.group.add(this._motionGroup);
+ }
+
+ _buildLabels() {
+ // Create text labels using canvas textures
+ const labels = [
+ { text: 'CSI AMPLITUDE', pos: [0, 5.2, 0], parent: this._heatmapGroup },
+ { text: 'PHASE', pos: [0, 0.7, 0], parent: this._phaseGroup },
+ { text: 'DOPPLER SPECTRUM', pos: [0, 0.8, 0], parent: this._dopplerGroup },
+ { text: 'MOTION', pos: [0, 0.55, 0], parent: this._motionGroup }
+ ];
+
+ for (const label of labels) {
+ const sprite = this._createTextSprite(label.text, {
+ fontSize: 14,
+ color: '#5588aa',
+ bgColor: 'transparent'
+ });
+ sprite.position.set(...label.pos);
+ sprite.scale.set(1.2, 0.3, 1);
+ if (label.parent) {
+ label.parent.add(sprite);
+ } else {
+ this.group.add(sprite);
+ }
+ }
+ }
+
+ _createTextSprite(text, opts = {}) {
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ canvas.width = 256;
+ canvas.height = 64;
+
+ if (opts.bgColor && opts.bgColor !== 'transparent') {
+ ctx.fillStyle = opts.bgColor;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ }
+
+ ctx.font = `${opts.fontSize || 14}px monospace`;
+ ctx.fillStyle = opts.color || '#88aacc';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(text, canvas.width / 2, canvas.height / 2);
+
+ const texture = new THREE.CanvasTexture(canvas);
+ texture.needsUpdate = true;
+
+ const mat = new THREE.SpriteMaterial({
+ map: texture,
+ transparent: true,
+ depthWrite: false
+ });
+ return new THREE.Sprite(mat);
+ }
+
+ // Feed new CSI data
+ // data: { amplitude: Float32Array(30), phase: Float32Array(30), doppler: Float32Array(16), motionEnergy: number }
+ updateSignalData(data) {
+ if (!data) return;
+
+ // Amplitude: shift history and add new row
+ if (data.amplitude) {
+ this.amplitudeHistory.shift();
+ this.amplitudeHistory.push(new Float32Array(data.amplitude));
+ }
+
+ // Phase
+ if (data.phase) {
+ this.phaseData = new Float32Array(data.phase);
+ }
+
+ // Doppler
+ if (data.doppler) {
+ for (let i = 0; i < Math.min(data.doppler.length, this.config.dopplerBars); i++) {
+ this.dopplerData[i] = data.doppler[i];
+ }
+ }
+
+ // Motion energy
+ if (data.motionEnergy !== undefined) {
+ this.targetMotionEnergy = Math.max(0, Math.min(1, data.motionEnergy));
+ }
+ }
+
+ // Call each frame
+ update(delta, elapsed) {
+ this._updateHeatmap();
+ this._updatePhasePlot();
+ this._updateDoppler(delta);
+ this._updateMotionIndicator(delta, elapsed);
+ }
+
+ _updateHeatmap() {
+ const { subcarriers, timeSlots } = this.config;
+ for (let t = 0; t < timeSlots; t++) {
+ const row = this.amplitudeHistory[t];
+ for (let s = 0; s < subcarriers; s++) {
+ const cell = this._heatmapCells[t][s];
+ const val = row[s] || 0;
+ // Color: dark blue (0) -> cyan (0.5) -> yellow (0.8) -> red (1.0)
+ cell.material.color.setHSL(
+ 0.6 - val * 0.6, // hue: 0.6 (blue) -> 0 (red)
+ 0.9, // saturation
+ 0.1 + val * 0.5 // lightness: dim to bright
+ );
+ }
+ }
+ }
+
+ _updatePhasePlot() {
+ const posAttr = this._phaseLine.geometry.getAttribute('position');
+ const arr = posAttr.array;
+ const { subcarriers, phaseWidth, phaseHeight } = this.config;
+
+ for (let i = 0; i < subcarriers; i++) {
+ const x = (i / (subcarriers - 1)) * phaseWidth - phaseWidth / 2;
+ // Phase is in radians, normalize to [-1, 1] range then scale to height
+ const phase = this.phaseData[i] || 0;
+ const y = (phase / Math.PI) * (phaseHeight / 2);
+ arr[i * 3] = x;
+ arr[i * 3 + 1] = y;
+ arr[i * 3 + 2] = 0;
+ }
+ posAttr.needsUpdate = true;
+
+ // Color based on phase variance (more variance = more activity = greener/brighter)
+ let variance = 0;
+ let mean = 0;
+ for (let i = 0; i < subcarriers; i++) mean += this.phaseData[i] || 0;
+ mean /= subcarriers;
+ for (let i = 0; i < subcarriers; i++) {
+ const diff = (this.phaseData[i] || 0) - mean;
+ variance += diff * diff;
+ }
+ variance /= subcarriers;
+ const activity = Math.min(1, variance / 2);
+ this._phaseLine.material.color.setHSL(0.3 - activity * 0.15, 1.0, 0.35 + activity * 0.3);
+ }
+
+ _updateDoppler(delta) {
+ for (let i = 0; i < this._dopplerBars.length; i++) {
+ const bar = this._dopplerBars[i];
+ const target = this.dopplerData[i] || 0;
+ // Smooth bar height
+ const currentH = bar.scale.y;
+ bar.scale.y += (target * this.config.dopplerHeight - currentH) * Math.min(1, delta * 8);
+ bar.scale.y = Math.max(0.01, bar.scale.y);
+
+ // Position bar bottom at y=0
+ bar.position.y = bar.scale.y / 2;
+
+ // Color: blue (low) -> purple (mid) -> magenta (high)
+ const val = target;
+ bar.material.color.setHSL(
+ 0.7 - val * 0.3, // blue to magenta
+ 0.8,
+ 0.25 + val * 0.35
+ );
+ }
+ }
+
+ _updateMotionIndicator(delta, elapsed) {
+ // Smooth motion energy
+ this.motionEnergy += (this.targetMotionEnergy - this.motionEnergy) * Math.min(1, delta * 5);
+
+ const energy = this.motionEnergy;
+
+ // Core: grows and brightens with motion
+ const coreScale = 0.8 + energy * 0.7;
+ this._motionCore.scale.setScalar(coreScale);
+ this._motionCore.material.color.setHSL(
+ 0.3 - energy * 0.2, // green -> yellow-green
+ 1.0,
+ 0.15 + energy * 0.4
+ );
+ this._motionCore.material.opacity = 0.4 + energy * 0.5;
+
+ // Ring
+ this._motionRing.material.opacity = 0.15 + energy * 0.5;
+ this._motionRing.material.color.setHSL(0.3 - energy * 0.15, 1.0, 0.4 + energy * 0.3);
+
+ // Pulse rings
+ for (const ring of this._pulseRings) {
+ const phase = ring.userData.phase + elapsed * (1 + energy * 3);
+ const t = (Math.sin(phase) + 1) / 2;
+ const scale = 1 + t * energy * 2;
+ ring.scale.setScalar(scale);
+ ring.material.opacity = (1 - t) * energy * 0.4;
+ }
+ }
+
+ // Generate synthetic demo signal data
+ static generateDemoData(elapsed) {
+ const subcarriers = 30;
+ const dopplerBars = 16;
+
+ // Amplitude: sinusoidal pattern with noise simulating human movement
+ const amplitude = new Float32Array(subcarriers);
+ for (let i = 0; i < subcarriers; i++) {
+ const baseFreq = Math.sin(elapsed * 2 + i * 0.3) * 0.3;
+ const bodyEffect = Math.sin(elapsed * 0.8 + i * 0.15) * 0.25;
+ const noise = (Math.random() - 0.5) * 0.1;
+ amplitude[i] = Math.max(0, Math.min(1, 0.4 + baseFreq + bodyEffect + noise));
+ }
+
+ // Phase: linear with perturbations from movement
+ const phase = new Float32Array(subcarriers);
+ for (let i = 0; i < subcarriers; i++) {
+ const linearPhase = (i / subcarriers) * Math.PI * 2;
+ const bodyPhase = Math.sin(elapsed * 1.5 + i * 0.2) * 0.8;
+ phase[i] = linearPhase + bodyPhase;
+ }
+
+ // Doppler: spectral peaks from movement velocity
+ const doppler = new Float32Array(dopplerBars);
+ const centerBin = dopplerBars / 2 + Math.sin(elapsed * 0.7) * 3;
+ for (let i = 0; i < dopplerBars; i++) {
+ const dist = Math.abs(i - centerBin);
+ doppler[i] = Math.max(0, Math.exp(-dist * dist * 0.15) * (0.6 + Math.sin(elapsed * 1.2) * 0.3));
+ doppler[i] += (Math.random() - 0.5) * 0.05;
+ doppler[i] = Math.max(0, Math.min(1, doppler[i]));
+ }
+
+ // Motion energy: pulsating
+ const motionEnergy = (Math.sin(elapsed * 0.5) + 1) / 2 * 0.7 + 0.15;
+
+ return { amplitude, phase, doppler, motionEnergy };
+ }
+
+ getGroup() {
+ return this.group;
+ }
+
+ dispose() {
+ this.group.traverse((child) => {
+ if (child.geometry) child.geometry.dispose();
+ if (child.material) {
+ if (child.material.map) child.material.map.dispose();
+ child.material.dispose();
+ }
+ });
+ this.scene.remove(this.group);
+ }
+}
diff --git a/ui/services/websocket-client.js b/ui/services/websocket-client.js
new file mode 100644
index 0000000..93428b8
--- /dev/null
+++ b/ui/services/websocket-client.js
@@ -0,0 +1,258 @@
+// WebSocket Client for Three.js Visualization - WiFi DensePose
+// Connects to ws://localhost:8000/ws/pose and manages real-time data flow
+
+export class WebSocketClient {
+ constructor(options = {}) {
+ this.url = options.url || 'ws://localhost:8000/ws/pose';
+ this.ws = null;
+ this.state = 'disconnected'; // disconnected, connecting, connected, error
+ this.isRealData = false;
+
+ // Reconnection settings
+ this.reconnectAttempts = 0;
+ this.maxReconnectAttempts = options.maxReconnectAttempts || 15;
+ this.reconnectDelays = [500, 1000, 2000, 4000, 8000, 15000, 30000];
+ this.reconnectTimer = null;
+ this.autoReconnect = options.autoReconnect !== false;
+
+ // Heartbeat
+ this.heartbeatInterval = null;
+ this.heartbeatFrequency = options.heartbeatFrequency || 25000;
+ this.lastPong = 0;
+
+ // Metrics
+ this.metrics = {
+ messageCount: 0,
+ errorCount: 0,
+ connectTime: null,
+ lastMessageTime: null,
+ latency: 0,
+ bytesReceived: 0
+ };
+
+ // Callbacks
+ this._onMessage = options.onMessage || (() => {});
+ this._onStateChange = options.onStateChange || (() => {});
+ this._onError = options.onError || (() => {});
+ }
+
+ // Attempt to connect
+ connect() {
+ if (this.state === 'connecting' || this.state === 'connected') {
+ console.warn('[WS-VIZ] Already connected or connecting');
+ return;
+ }
+
+ this._setState('connecting');
+ console.log(`[WS-VIZ] Connecting to ${this.url}`);
+
+ try {
+ this.ws = new WebSocket(this.url);
+ this.ws.binaryType = 'arraybuffer';
+
+ this.ws.onopen = () => this._handleOpen();
+ this.ws.onmessage = (event) => this._handleMessage(event);
+ this.ws.onerror = (event) => this._handleError(event);
+ this.ws.onclose = (event) => this._handleClose(event);
+
+ // Connection timeout
+ this._connectTimeout = setTimeout(() => {
+ if (this.state === 'connecting') {
+ console.warn('[WS-VIZ] Connection timeout');
+ this.ws.close();
+ this._setState('error');
+ this._scheduleReconnect();
+ }
+ }, 8000);
+
+ } catch (err) {
+ console.error('[WS-VIZ] Failed to create WebSocket:', err);
+ this._setState('error');
+ this._onError(err);
+ this._scheduleReconnect();
+ }
+ }
+
+ disconnect() {
+ this.autoReconnect = false;
+ this._clearTimers();
+
+ if (this.ws) {
+ this.ws.onclose = null; // Prevent reconnect on intentional close
+ if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
+ this.ws.close(1000, 'Client disconnect');
+ }
+ this.ws = null;
+ }
+
+ this._setState('disconnected');
+ this.isRealData = false;
+ console.log('[WS-VIZ] Disconnected');
+ }
+
+ // Send a message
+ send(data) {
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
+ console.warn('[WS-VIZ] Cannot send - not connected');
+ return false;
+ }
+
+ const msg = typeof data === 'string' ? data : JSON.stringify(data);
+ this.ws.send(msg);
+ return true;
+ }
+
+ _handleOpen() {
+ clearTimeout(this._connectTimeout);
+ this.reconnectAttempts = 0;
+ this.metrics.connectTime = Date.now();
+ this._setState('connected');
+ console.log('[WS-VIZ] Connected successfully');
+
+ // Start heartbeat
+ this._startHeartbeat();
+
+ // Request initial state
+ this.send({ type: 'get_status', timestamp: Date.now() });
+ }
+
+ _handleMessage(event) {
+ this.metrics.messageCount++;
+ this.metrics.lastMessageTime = Date.now();
+
+ const rawSize = typeof event.data === 'string' ? event.data.length : event.data.byteLength;
+ this.metrics.bytesReceived += rawSize;
+
+ try {
+ const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
+
+ // Handle pong
+ if (data.type === 'pong') {
+ this.lastPong = Date.now();
+ if (data.timestamp) {
+ this.metrics.latency = Date.now() - data.timestamp;
+ }
+ return;
+ }
+
+ // Handle connection_established
+ if (data.type === 'connection_established') {
+ console.log('[WS-VIZ] Server confirmed connection:', data.payload);
+ return;
+ }
+
+ // Detect real vs mock data from metadata
+ if (data.data && data.data.metadata) {
+ this.isRealData = data.data.metadata.mock_data === false && data.data.metadata.source !== 'mock';
+ } else if (data.metadata) {
+ this.isRealData = data.metadata.mock_data === false;
+ }
+
+ // Calculate latency from message timestamp
+ if (data.timestamp) {
+ const msgTime = new Date(data.timestamp).getTime();
+ if (!isNaN(msgTime)) {
+ this.metrics.latency = Date.now() - msgTime;
+ }
+ }
+
+ // Forward to callback
+ this._onMessage(data);
+
+ } catch (err) {
+ this.metrics.errorCount++;
+ console.error('[WS-VIZ] Failed to parse message:', err);
+ }
+ }
+
+ _handleError(event) {
+ this.metrics.errorCount++;
+ console.error('[WS-VIZ] WebSocket error:', event);
+ this._onError(event);
+ }
+
+ _handleClose(event) {
+ clearTimeout(this._connectTimeout);
+ this._stopHeartbeat();
+ this.ws = null;
+
+ const wasConnected = this.state === 'connected';
+ console.log(`[WS-VIZ] Connection closed: code=${event.code}, reason=${event.reason}, clean=${event.wasClean}`);
+
+ if (event.wasClean || !this.autoReconnect) {
+ this._setState('disconnected');
+ } else {
+ this._setState('error');
+ this._scheduleReconnect();
+ }
+ }
+
+ _setState(newState) {
+ if (this.state === newState) return;
+ const oldState = this.state;
+ this.state = newState;
+ this._onStateChange(newState, oldState);
+ }
+
+ _startHeartbeat() {
+ this._stopHeartbeat();
+ this.heartbeatInterval = setInterval(() => {
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ this.send({ type: 'ping', timestamp: Date.now() });
+ }
+ }, this.heartbeatFrequency);
+ }
+
+ _stopHeartbeat() {
+ if (this.heartbeatInterval) {
+ clearInterval(this.heartbeatInterval);
+ this.heartbeatInterval = null;
+ }
+ }
+
+ _scheduleReconnect() {
+ if (!this.autoReconnect) return;
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
+ console.error('[WS-VIZ] Max reconnect attempts reached');
+ this._setState('error');
+ return;
+ }
+
+ const delayIdx = Math.min(this.reconnectAttempts, this.reconnectDelays.length - 1);
+ const delay = this.reconnectDelays[delayIdx];
+ this.reconnectAttempts++;
+
+ console.log(`[WS-VIZ] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
+
+ this.reconnectTimer = setTimeout(() => {
+ this.connect();
+ }, delay);
+ }
+
+ _clearTimers() {
+ clearTimeout(this._connectTimeout);
+ clearTimeout(this.reconnectTimer);
+ this._stopHeartbeat();
+ }
+
+ getMetrics() {
+ return {
+ ...this.metrics,
+ state: this.state,
+ isRealData: this.isRealData,
+ reconnectAttempts: this.reconnectAttempts,
+ uptime: this.metrics.connectTime ? (Date.now() - this.metrics.connectTime) / 1000 : 0
+ };
+ }
+
+ isConnected() {
+ return this.state === 'connected';
+ }
+
+ dispose() {
+ this.disconnect();
+ this._onMessage = () => {};
+ this._onStateChange = () => {};
+ this._onError = () => {};
+ }
+}