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
468 lines
15 KiB
JavaScript
468 lines
15 KiB
JavaScript
// 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);
|
|
}
|
|
}
|