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
This commit is contained in:
Claude
2026-02-28 06:26:10 +00:00
parent b3916386a3
commit dd382824fe
5 changed files with 1642 additions and 0 deletions

View File

@@ -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)

View File

@@ -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 = `
<style>
#viz-hud {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 100;
font-family: 'Courier New', 'Consolas', monospace;
color: #88ccff;
}
#viz-hud * {
pointer-events: none;
}
/* Data source banner */
.hud-banner {
position: absolute;
top: 0;
left: 0;
right: 0;
text-align: center;
padding: 6px 0;
font-size: 14px;
font-weight: bold;
letter-spacing: 3px;
text-transform: uppercase;
z-index: 110;
}
.hud-banner.mock {
background: linear-gradient(90deg, rgba(180,100,0,0.85) 0%, rgba(200,120,0,0.85) 50%, rgba(180,100,0,0.85) 100%);
color: #fff;
border-bottom: 2px solid #ff8800;
}
.hud-banner.real {
background: linear-gradient(90deg, rgba(0,120,60,0.85) 0%, rgba(0,160,80,0.85) 50%, rgba(0,120,60,0.85) 100%);
color: #fff;
border-bottom: 2px solid #00ff66;
animation: pulse-green 2s ease-in-out infinite;
}
@keyframes pulse-green {
0%, 100% { border-bottom-color: #00ff66; }
50% { border-bottom-color: #00cc44; }
}
/* Top-left: connection info */
.hud-top-left {
position: absolute;
top: 40px;
left: 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.hud-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
line-height: 1.4;
}
.hud-label {
color: #5588aa;
min-width: 65px;
text-transform: uppercase;
font-size: 9px;
letter-spacing: 1px;
}
.hud-value {
color: #aaddff;
font-weight: bold;
font-size: 12px;
}
/* Status dot */
.hud-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: 4px;
}
.hud-status-dot.connected {
background: #00ff66;
box-shadow: 0 0 6px #00ff66;
}
.hud-status-dot.disconnected {
background: #666;
}
.hud-status-dot.connecting {
background: #ffaa00;
box-shadow: 0 0 6px #ffaa00;
animation: blink 1s infinite;
}
.hud-status-dot.error {
background: #ff3344;
box-shadow: 0 0 6px #ff3344;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* Top-right: performance */
.hud-top-right {
position: absolute;
top: 40px;
right: 12px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.hud-fps {
font-size: 22px;
font-weight: bold;
color: #00ff88;
line-height: 1;
}
.hud-fps.low { color: #ff4444; }
.hud-fps.mid { color: #ffaa00; }
.hud-fps.high { color: #00ff88; }
/* Bottom-left: detection info */
.hud-bottom-left {
position: absolute;
bottom: 12px;
left: 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.hud-person-count {
font-size: 28px;
font-weight: bold;
line-height: 1;
}
.hud-confidence-bar {
width: 120px;
height: 6px;
background: rgba(20, 30, 50, 0.8);
border: 1px solid #223344;
border-radius: 3px;
overflow: hidden;
}
.hud-confidence-fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease, background 0.3s ease;
}
/* Bottom-right: sensing mode */
.hud-bottom-right {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.hud-mode-badge {
padding: 3px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: bold;
letter-spacing: 1px;
text-transform: uppercase;
}
.hud-mode-badge.csi {
background: rgba(0, 100, 200, 0.7);
border: 1px solid #0088ff;
color: #aaddff;
}
.hud-mode-badge.rssi {
background: rgba(100, 0, 200, 0.7);
border: 1px solid #8800ff;
color: #ddaaff;
}
.hud-mode-badge.mock {
background: rgba(120, 80, 0, 0.7);
border: 1px solid #ff8800;
color: #ffddaa;
}
/* Corner brackets decoration */
.hud-corner {
position: absolute;
width: 20px;
height: 20px;
border-color: rgba(100, 150, 200, 0.3);
border-style: solid;
}
.hud-corner.tl { top: 36px; left: 4px; border-width: 1px 0 0 1px; }
.hud-corner.tr { top: 36px; right: 4px; border-width: 1px 1px 0 0; }
.hud-corner.bl { bottom: 4px; left: 4px; border-width: 0 0 1px 1px; }
.hud-corner.br { bottom: 4px; right: 4px; border-width: 0 1px 1px 0; }
/* Controls hint */
.hud-controls-hint {
position: absolute;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: #445566;
text-align: center;
opacity: 0.6;
}
</style>
<!-- Data source banner -->
<div class="hud-banner mock" id="hud-banner">MOCK DATA</div>
<!-- Corner decorations -->
<div class="hud-corner tl"></div>
<div class="hud-corner tr"></div>
<div class="hud-corner bl"></div>
<div class="hud-corner br"></div>
<!-- Top-left: connection info -->
<div class="hud-top-left">
<div class="hud-row">
<span class="hud-status-dot disconnected" id="hud-status-dot"></span>
<span class="hud-value" id="hud-conn-status">Disconnected</span>
</div>
<div class="hud-row">
<span class="hud-label">Latency</span>
<span class="hud-value" id="hud-latency">-- ms</span>
</div>
<div class="hud-row">
<span class="hud-label">Messages</span>
<span class="hud-value" id="hud-msg-count">0</span>
</div>
<div class="hud-row">
<span class="hud-label">Uptime</span>
<span class="hud-value" id="hud-uptime">0s</span>
</div>
</div>
<!-- Top-right: FPS -->
<div class="hud-top-right">
<div class="hud-fps high" id="hud-fps">-- FPS</div>
<div class="hud-row">
<span class="hud-label">Frame</span>
<span class="hud-value" id="hud-frame-time">-- ms</span>
</div>
</div>
<!-- Bottom-left: detection info -->
<div class="hud-bottom-left">
<div class="hud-row">
<span class="hud-label">Persons</span>
<span class="hud-person-count hud-value" id="hud-person-count">0</span>
</div>
<div class="hud-row">
<span class="hud-label">Confidence</span>
<span class="hud-value" id="hud-confidence">0%</span>
</div>
<div class="hud-confidence-bar">
<div class="hud-confidence-fill" id="hud-confidence-fill" style="width: 0%; background: #334455;"></div>
</div>
</div>
<!-- Bottom-right: sensing mode -->
<div class="hud-bottom-right">
<div class="hud-mode-badge mock" id="hud-mode-badge">MOCK</div>
<div class="hud-row" style="margin-top: 4px;">
<span class="hud-label">WiFi DensePose</span>
</div>
</div>
<!-- Controls hint -->
<div class="hud-controls-hint">
Drag to orbit | Scroll to zoom | Right-click to pan
</div>
`;
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);
}
}
}

View File

@@ -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);
}
}

467
ui/components/signal-viz.js Normal file
View File

@@ -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);
}
}

View File

@@ -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 = () => {};
}
}