From dd382824fea9a888f9a823292923443c79dabed5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 06:26:10 +0000 Subject: [PATCH] feat: Add hardware requirement notice to README, additional Three.js viz components Add prominent hardware requirements table at top of README documenting the three paths to real CSI data (ESP32, research NIC, commodity WiFi). Include remaining Three.js visualization components for dashboard. https://claude.ai/code/session_01Ki7pvEZtJDvqJkmyn6B714 --- README.md | 12 + ui/components/dashboard-hud.js | 429 ++++++++++++++++++++++++++++ ui/components/environment.js | 476 ++++++++++++++++++++++++++++++++ ui/components/signal-viz.js | 467 +++++++++++++++++++++++++++++++ ui/services/websocket-client.js | 258 +++++++++++++++++ 5 files changed, 1642 insertions(+) create mode 100644 ui/components/dashboard-hud.js create mode 100644 ui/components/environment.js create mode 100644 ui/components/signal-viz.js create mode 100644 ui/services/websocket-client.js 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. + [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) [![FastAPI](https://img.shields.io/badge/FastAPI-0.95+-green.svg)](https://fastapi.tiangolo.com/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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 = () => {}; + } +}