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
|
||||
|
||||
> **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