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:
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user