feat: Add 12 ADRs for RuVector RVF integration and proof-of-reality #31

Merged
ruvnet merged 33 commits from claude/integrate-ruvector-rvf-mF1Hp into main 2026-02-28 22:43:59 +08:00
5 changed files with 1642 additions and 0 deletions
Showing only changes of commit dd382824fe - Show all commits

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