feat: Add 12 ADRs for RuVector RVF integration and proof-of-reality #31
12
README.md
12
README.md
@@ -1,5 +1,17 @@
|
|||||||
# WiFi DensePose
|
# WiFi DensePose
|
||||||
|
|
||||||
|
> **Hardware Required:** This system processes real WiFi Channel State Information (CSI) data. To capture live CSI you need one of:
|
||||||
|
>
|
||||||
|
> | Option | Hardware | Cost | Capabilities |
|
||||||
|
> |--------|----------|------|-------------|
|
||||||
|
> | **ESP32 Mesh** (recommended) | 3-6x ESP32-S3 boards + consumer WiFi router | ~$54 | Presence, motion, respiration detection |
|
||||||
|
> | **Research NIC** | Intel 5300 or Atheros AR9580 (discontinued) | ~$50-100 | Full CSI with 3x3 MIMO |
|
||||||
|
> | **Commodity WiFi** | Any Linux laptop with WiFi | $0 | Presence and coarse motion only (RSSI-based) |
|
||||||
|
>
|
||||||
|
> Without CSI-capable hardware, you can verify the signal processing pipeline using the included deterministic reference signal: `python v1/data/proof/verify.py`
|
||||||
|
>
|
||||||
|
> See [docs/adr/ADR-012-esp32-csi-sensor-mesh.md](docs/adr/ADR-012-esp32-csi-sensor-mesh.md) for the ESP32 setup guide and [docs/adr/ADR-013-feature-level-sensing-commodity-gear.md](docs/adr/ADR-013-feature-level-sensing-commodity-gear.md) for the zero-cost RSSI path.
|
||||||
|
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
[](https://fastapi.tiangolo.com/)
|
[](https://fastapi.tiangolo.com/)
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|||||||
429
ui/components/dashboard-hud.js
Normal file
429
ui/components/dashboard-hud.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
476
ui/components/environment.js
Normal file
476
ui/components/environment.js
Normal 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
467
ui/components/signal-viz.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
258
ui/services/websocket-client.js
Normal file
258
ui/services/websocket-client.js
Normal 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 = () => {};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user