222 lines
7.3 KiB
JavaScript
222 lines
7.3 KiB
JavaScript
/**
|
|
* Module E — "Statistical Convergence Engine"
|
|
* RSSI waveform, person orbs, classification, fall alert, metric bars
|
|
*/
|
|
import * as THREE from 'three';
|
|
|
|
const WAVEFORM_POINTS = 120;
|
|
|
|
export class ConvergenceEngine {
|
|
constructor(scene, panelGroup) {
|
|
this.group = new THREE.Group();
|
|
if (panelGroup) panelGroup.add(this.group);
|
|
else scene.add(this.group);
|
|
|
|
// --- RSSI Waveform (scrolling line) ---
|
|
this._rssiHistory = new Float32Array(WAVEFORM_POINTS);
|
|
const waveGeo = new THREE.BufferGeometry();
|
|
this._wavePositions = new Float32Array(WAVEFORM_POINTS * 3);
|
|
for (let i = 0; i < WAVEFORM_POINTS; i++) {
|
|
this._wavePositions[i * 3] = (i / WAVEFORM_POINTS) * 6 - 3; // x: -3 to 3
|
|
this._wavePositions[i * 3 + 1] = 0;
|
|
this._wavePositions[i * 3 + 2] = 0;
|
|
}
|
|
waveGeo.setAttribute('position', new THREE.BufferAttribute(this._wavePositions, 3));
|
|
const waveMat = new THREE.LineBasicMaterial({
|
|
color: 0x00d4ff,
|
|
transparent: true,
|
|
opacity: 0.8,
|
|
blending: THREE.AdditiveBlending,
|
|
});
|
|
this._waveform = new THREE.Line(waveGeo, waveMat);
|
|
this._waveform.position.y = 1.5;
|
|
this.group.add(this._waveform);
|
|
|
|
// Waveform glow (thicker, dimmer duplicate)
|
|
const glowMat = new THREE.LineBasicMaterial({
|
|
color: 0x00d4ff,
|
|
transparent: true,
|
|
opacity: 0.2,
|
|
linewidth: 2,
|
|
blending: THREE.AdditiveBlending,
|
|
});
|
|
this._waveGlow = new THREE.Line(waveGeo.clone(), glowMat);
|
|
this._waveGlow.position.y = 1.5;
|
|
this._waveGlow.scale.set(1, 1.3, 1);
|
|
this.group.add(this._waveGlow);
|
|
|
|
// --- Person orbs (up to 4) ---
|
|
this._personOrbs = [];
|
|
for (let i = 0; i < 4; i++) {
|
|
const orbGeo = new THREE.SphereGeometry(0.2, 16, 16);
|
|
const orbMat = new THREE.MeshBasicMaterial({
|
|
color: 0xff8800,
|
|
transparent: true,
|
|
opacity: 0,
|
|
blending: THREE.AdditiveBlending,
|
|
});
|
|
const orb = new THREE.Mesh(orbGeo, orbMat);
|
|
orb.position.set(-2 + i * 1.2, -0.5, 0);
|
|
this.group.add(orb);
|
|
|
|
const light = new THREE.PointLight(0xff8800, 0, 3);
|
|
orb.add(light);
|
|
|
|
this._personOrbs.push({ mesh: orb, light, mat: orbMat });
|
|
}
|
|
|
|
// --- Classification text sprite ---
|
|
this._classCanvas = document.createElement('canvas');
|
|
this._classCanvas.width = 256;
|
|
this._classCanvas.height = 48;
|
|
this._classCtx = this._classCanvas.getContext('2d');
|
|
this._classTex = new THREE.CanvasTexture(this._classCanvas);
|
|
const classMat = new THREE.SpriteMaterial({
|
|
map: this._classTex,
|
|
transparent: true,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false,
|
|
});
|
|
this._classSprite = new THREE.Sprite(classMat);
|
|
this._classSprite.scale.set(3, 0.6, 1);
|
|
this._classSprite.position.y = 0.3;
|
|
this.group.add(this._classSprite);
|
|
|
|
// --- Fall alert ring ---
|
|
const alertGeo = new THREE.TorusGeometry(2.5, 0.05, 8, 48);
|
|
this._alertMat = new THREE.MeshBasicMaterial({
|
|
color: 0xff2244,
|
|
transparent: true,
|
|
opacity: 0,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false,
|
|
});
|
|
this._alertRing = new THREE.Mesh(alertGeo, this._alertMat);
|
|
this._alertRing.rotation.x = Math.PI / 2;
|
|
this._alertRing.position.y = -1;
|
|
this.group.add(this._alertRing);
|
|
|
|
// --- Metric bars (3: frame rate, confidence, variance) ---
|
|
this._metricBars = [];
|
|
const barLabels = ['CONF', 'VAR', 'SPEC'];
|
|
for (let i = 0; i < 3; i++) {
|
|
const barGeo = new THREE.PlaneGeometry(0.15, 1.5);
|
|
const barMat = new THREE.MeshBasicMaterial({
|
|
color: [0x00d4ff, 0x8844ff, 0xff8800][i],
|
|
transparent: true,
|
|
opacity: 0.5,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
const bar = new THREE.Mesh(barGeo, barMat);
|
|
bar.position.set(2 + i * 0.4, -1.2, 0);
|
|
this.group.add(bar);
|
|
this._metricBars.push({ mesh: bar, mat: barMat });
|
|
}
|
|
|
|
this._rssiHead = 0;
|
|
this._lastClassification = '';
|
|
}
|
|
|
|
update(dt, elapsed, data) {
|
|
const features = data?.features || {};
|
|
const classification = data?.classification || {};
|
|
const persons = data?.persons || [];
|
|
const estPersons = data?.estimated_persons || 0;
|
|
|
|
// --- Update RSSI waveform ---
|
|
const rssi = features.mean_rssi || -50;
|
|
this._rssiHistory[this._rssiHead] = rssi;
|
|
this._rssiHead = (this._rssiHead + 1) % WAVEFORM_POINTS;
|
|
|
|
for (let i = 0; i < WAVEFORM_POINTS; i++) {
|
|
const histIdx = (this._rssiHead + i) % WAVEFORM_POINTS;
|
|
const val = this._rssiHistory[histIdx];
|
|
// Normalize RSSI (-80 to -20 range) to -1.5 to 1.5
|
|
this._wavePositions[i * 3 + 1] = ((val + 50) / 30) * 1.5;
|
|
}
|
|
this._waveform.geometry.attributes.position.needsUpdate = true;
|
|
|
|
// Copy to glow
|
|
const glowPos = this._waveGlow.geometry.attributes.position;
|
|
glowPos.array.set(this._wavePositions);
|
|
glowPos.needsUpdate = true;
|
|
|
|
// --- Person orbs ---
|
|
for (let i = 0; i < this._personOrbs.length; i++) {
|
|
const { mesh, light, mat } = this._personOrbs[i];
|
|
if (i < estPersons) {
|
|
mat.opacity = 0.7;
|
|
light.intensity = 1.0 + Math.sin(elapsed * 3 + i * 1.5) * 0.5;
|
|
const pulse = 1.0 + Math.sin(elapsed * 2 + i) * 0.15;
|
|
mesh.scale.set(pulse, pulse, pulse);
|
|
} else {
|
|
mat.opacity = 0.05;
|
|
light.intensity = 0;
|
|
mesh.scale.set(0.5, 0.5, 0.5);
|
|
}
|
|
}
|
|
|
|
// --- Classification text ---
|
|
const motionLevel = classification.motion_level || 'absent';
|
|
const label = motionLevel.toUpperCase().replace('_', ' ');
|
|
if (label !== this._lastClassification) {
|
|
this._lastClassification = label;
|
|
const ctx = this._classCtx;
|
|
ctx.clearRect(0, 0, 256, 48);
|
|
ctx.font = '600 24px "Courier New", monospace';
|
|
ctx.textAlign = 'center';
|
|
|
|
if (motionLevel === 'active') ctx.fillStyle = '#ff8800';
|
|
else if (motionLevel.includes('present')) ctx.fillStyle = '#00d4ff';
|
|
else ctx.fillStyle = '#445566';
|
|
|
|
ctx.fillText(label, 128, 32);
|
|
this._classTex.needsUpdate = true;
|
|
}
|
|
|
|
// --- Fall alert ---
|
|
const fallDetected = classification.fall_detected || false;
|
|
if (fallDetected) {
|
|
this._alertMat.opacity = 0.3 + Math.abs(Math.sin(elapsed * 6)) * 0.5;
|
|
const scale = 1.0 + Math.sin(elapsed * 4) * 0.1;
|
|
this._alertRing.scale.set(scale, scale, 1);
|
|
} else {
|
|
this._alertMat.opacity = 0;
|
|
}
|
|
|
|
// --- Metric bars ---
|
|
const confidence = classification.confidence || 0;
|
|
const variance = Math.min(1, (features.variance || 0) / 5);
|
|
const spectral = Math.min(1, (features.spectral_power || 0) / 0.5);
|
|
const values = [confidence, variance, spectral];
|
|
|
|
for (let i = 0; i < 3; i++) {
|
|
const bar = this._metricBars[i];
|
|
const v = values[i];
|
|
bar.mesh.scale.y = Math.max(0.05, v);
|
|
bar.mesh.position.y = -1.2 + v * 0.75;
|
|
bar.mat.opacity = 0.3 + v * 0.4;
|
|
}
|
|
}
|
|
|
|
dispose() {
|
|
this._waveform.geometry.dispose();
|
|
this._waveform.material.dispose();
|
|
this._waveGlow.geometry.dispose();
|
|
this._waveGlow.material.dispose();
|
|
this._alertRing.geometry.dispose();
|
|
this._alertMat.dispose();
|
|
this._classTex.dispose();
|
|
for (const { mesh, mat } of this._personOrbs) {
|
|
mesh.geometry.dispose();
|
|
mat.dispose();
|
|
}
|
|
for (const { mesh, mat } of this._metricBars) {
|
|
mesh.geometry.dispose();
|
|
mat.dispose();
|
|
}
|
|
}
|
|
}
|