feat: Add hardware requirement notice to README, additional Three.js viz components
Add prominent hardware requirements table at top of README documenting the three paths to real CSI data (ESP32, research NIC, commodity WiFi). Include remaining Three.js visualization components for dashboard. https://claude.ai/code/session_01Ki7pvEZtJDvqJkmyn6B714
This commit is contained in:
12
README.md
12
README.md
@@ -1,5 +1,17 @@
|
||||
# WiFi DensePose
|
||||
|
||||
> **Hardware Required:** This system processes real WiFi Channel State Information (CSI) data. To capture live CSI you need one of:
|
||||
>
|
||||
> | Option | Hardware | Cost | Capabilities |
|
||||
> |--------|----------|------|-------------|
|
||||
> | **ESP32 Mesh** (recommended) | 3-6x ESP32-S3 boards + consumer WiFi router | ~$54 | Presence, motion, respiration detection |
|
||||
> | **Research NIC** | Intel 5300 or Atheros AR9580 (discontinued) | ~$50-100 | Full CSI with 3x3 MIMO |
|
||||
> | **Commodity WiFi** | Any Linux laptop with WiFi | $0 | Presence and coarse motion only (RSSI-based) |
|
||||
>
|
||||
> Without CSI-capable hardware, you can verify the signal processing pipeline using the included deterministic reference signal: `python v1/data/proof/verify.py`
|
||||
>
|
||||
> See [docs/adr/ADR-012-esp32-csi-sensor-mesh.md](docs/adr/ADR-012-esp32-csi-sensor-mesh.md) for the ESP32 setup guide and [docs/adr/ADR-013-feature-level-sensing-commodity-gear.md](docs/adr/ADR-013-feature-level-sensing-commodity-gear.md) for the zero-cost RSSI path.
|
||||
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://fastapi.tiangolo.com/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
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