188 lines
6.1 KiB
JavaScript
188 lines
6.1 KiB
JavaScript
/**
|
|
* Module B — "Vital Signs Oracle"
|
|
* Breathing/HR as orbital torus rings with beat markers + trail particles
|
|
*/
|
|
import * as THREE from 'three';
|
|
|
|
export class VitalsOracle {
|
|
constructor(scene, panelGroup) {
|
|
this.group = new THREE.Group();
|
|
if (panelGroup) panelGroup.add(this.group);
|
|
else scene.add(this.group);
|
|
|
|
// Outer torus — breathing (violet)
|
|
const breathGeo = new THREE.TorusGeometry(1.8, 0.06, 16, 64);
|
|
this._breathMat = new THREE.MeshBasicMaterial({
|
|
color: 0x8844ff,
|
|
transparent: true,
|
|
opacity: 0.7,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false,
|
|
});
|
|
this._breathRing = new THREE.Mesh(breathGeo, this._breathMat);
|
|
this._breathRing.rotation.x = Math.PI * 0.4;
|
|
this.group.add(this._breathRing);
|
|
|
|
// Inner torus — heart rate (crimson)
|
|
const hrGeo = new THREE.TorusGeometry(1.2, 0.04, 16, 64);
|
|
this._hrMat = new THREE.MeshBasicMaterial({
|
|
color: 0xff2244,
|
|
transparent: true,
|
|
opacity: 0.6,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false,
|
|
});
|
|
this._hrRing = new THREE.Mesh(hrGeo, this._hrMat);
|
|
this._hrRing.rotation.x = Math.PI * 0.5;
|
|
this._hrRing.rotation.z = Math.PI * 0.15;
|
|
this.group.add(this._hrRing);
|
|
|
|
// Center orb
|
|
const orbGeo = new THREE.SphereGeometry(0.35, 24, 24);
|
|
this._orbMat = new THREE.MeshBasicMaterial({
|
|
color: 0x00d4ff,
|
|
transparent: true,
|
|
opacity: 0.5,
|
|
blending: THREE.AdditiveBlending,
|
|
});
|
|
this._orb = new THREE.Mesh(orbGeo, this._orbMat);
|
|
this.group.add(this._orb);
|
|
|
|
// Bloom point light
|
|
this._light = new THREE.PointLight(0x00d4ff, 1.5, 8);
|
|
this.group.add(this._light);
|
|
|
|
// Trail particles along breathing ring
|
|
const trailCount = 120;
|
|
const trailGeo = new THREE.BufferGeometry();
|
|
const trailPos = new Float32Array(trailCount * 3);
|
|
const trailSizes = new Float32Array(trailCount);
|
|
for (let i = 0; i < trailCount; i++) {
|
|
const angle = (i / trailCount) * Math.PI * 2;
|
|
trailPos[i * 3] = Math.cos(angle) * 1.8;
|
|
trailPos[i * 3 + 1] = 0;
|
|
trailPos[i * 3 + 2] = Math.sin(angle) * 1.8;
|
|
trailSizes[i] = 3;
|
|
}
|
|
trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPos, 3));
|
|
trailGeo.setAttribute('size', new THREE.BufferAttribute(trailSizes, 1));
|
|
|
|
const trailMat = new THREE.PointsMaterial({
|
|
color: 0x8844ff,
|
|
size: 0.08,
|
|
transparent: true,
|
|
opacity: 0.4,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false,
|
|
sizeAttenuation: true,
|
|
});
|
|
this._trails = new THREE.Points(trailGeo, trailMat);
|
|
this._trails.rotation.x = Math.PI * 0.4;
|
|
this.group.add(this._trails);
|
|
|
|
// Beat flash sprites
|
|
this._beatFlash = this._createBeatSprite(0xff2244);
|
|
this.group.add(this._beatFlash);
|
|
this._beatTimer = 0;
|
|
this._lastBeatTime = 0;
|
|
|
|
// State
|
|
this._breathBpm = 0;
|
|
this._hrBpm = 0;
|
|
this._breathConf = 0;
|
|
this._hrConf = 0;
|
|
}
|
|
|
|
_createBeatSprite(color) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = 64;
|
|
canvas.height = 64;
|
|
const ctx = canvas.getContext('2d');
|
|
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
|
|
gradient.addColorStop(0, `rgba(255, 34, 68, 1)`);
|
|
gradient.addColorStop(0.3, `rgba(255, 34, 68, 0.5)`);
|
|
gradient.addColorStop(1, `rgba(255, 34, 68, 0)`);
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(0, 0, 64, 64);
|
|
|
|
const tex = new THREE.CanvasTexture(canvas);
|
|
const mat = new THREE.SpriteMaterial({
|
|
map: tex,
|
|
transparent: true,
|
|
blending: THREE.AdditiveBlending,
|
|
depthWrite: false,
|
|
});
|
|
const sprite = new THREE.Sprite(mat);
|
|
sprite.scale.set(0, 0, 0);
|
|
return sprite;
|
|
}
|
|
|
|
update(dt, elapsed, data) {
|
|
const vs = data?.vital_signs || {};
|
|
this._breathBpm = vs.breathing_rate_bpm || 0;
|
|
this._hrBpm = vs.heart_rate_bpm || 0;
|
|
this._breathConf = vs.breathing_confidence || 0;
|
|
this._hrConf = vs.heart_rate_confidence || 0;
|
|
|
|
// Breathing ring pulsation
|
|
const breathFreq = this._breathBpm / 60;
|
|
const breathPulse = breathFreq > 0 ? Math.sin(elapsed * Math.PI * 2 * breathFreq) : 0;
|
|
const breathScale = 1.0 + breathPulse * 0.08 * this._breathConf;
|
|
this._breathRing.scale.set(breathScale, breathScale, 1);
|
|
this._breathMat.opacity = 0.3 + this._breathConf * 0.5;
|
|
|
|
// HR ring pulsation (faster)
|
|
const hrFreq = this._hrBpm / 60;
|
|
const hrPulse = hrFreq > 0 ? Math.sin(elapsed * Math.PI * 2 * hrFreq) : 0;
|
|
const hrScale = 1.0 + hrPulse * 0.06 * this._hrConf;
|
|
this._hrRing.scale.set(hrScale, hrScale, 1);
|
|
this._hrMat.opacity = 0.2 + this._hrConf * 0.5;
|
|
|
|
// Slow rotation
|
|
this._breathRing.rotation.z = elapsed * 0.1;
|
|
this._hrRing.rotation.z = -elapsed * 0.15;
|
|
this._trails.rotation.z = elapsed * 0.1;
|
|
|
|
// Center orb pulse
|
|
const orbPulse = 1.0 + breathPulse * 0.1;
|
|
this._orb.scale.set(orbPulse, orbPulse, orbPulse);
|
|
this._light.intensity = 0.8 + Math.abs(breathPulse) * 1.0;
|
|
|
|
// Beat flash on HR cycle
|
|
if (hrFreq > 0) {
|
|
this._beatTimer += dt;
|
|
const beatInterval = 1 / hrFreq;
|
|
if (this._beatTimer >= beatInterval) {
|
|
this._beatTimer -= beatInterval;
|
|
this._lastBeatTime = elapsed;
|
|
}
|
|
const beatAge = elapsed - this._lastBeatTime;
|
|
const flashSize = Math.max(0, 1.2 - beatAge * 4) * this._hrConf;
|
|
this._beatFlash.scale.set(flashSize, flashSize, 1);
|
|
} else {
|
|
this._beatFlash.scale.set(0, 0, 0);
|
|
}
|
|
|
|
// Update trail particle sizes based on breathing
|
|
const sizes = this._trails.geometry.attributes.size;
|
|
if (sizes) {
|
|
for (let i = 0; i < sizes.count; i++) {
|
|
const phase = (i / sizes.count) * Math.PI * 2 + elapsed * breathFreq * Math.PI * 2;
|
|
sizes.array[i] = 0.04 + Math.abs(Math.sin(phase)) * 0.06 * this._breathConf;
|
|
}
|
|
sizes.needsUpdate = true;
|
|
}
|
|
}
|
|
|
|
dispose() {
|
|
this._breathRing.geometry.dispose();
|
|
this._breathMat.dispose();
|
|
this._hrRing.geometry.dispose();
|
|
this._hrMat.dispose();
|
|
this._orb.geometry.dispose();
|
|
this._orbMat.dispose();
|
|
this._trails.geometry.dispose();
|
|
this._trails.material.dispose();
|
|
}
|
|
}
|