fix: remove nested dirs, CLAUDE.local.md, and binary from gh-pages

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-03-05 10:57:29 -05:00
parent 5c5149dc38
commit c429fc698f
12 changed files with 0 additions and 5054 deletions

View File

@ -1,698 +0,0 @@
/* ============================================================
RuView Observatory Foundation Color Scheme
Warm dark background, electric green wireframe, amber data
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=JetBrains+Mono:wght@400;600&display=swap');
:root {
--bg-deep: #080c14;
--bg-panel: rgba(8, 16, 28, 0.85);
--bg-panel-border: rgba(0, 210, 120, 0.2);
--green-glow: #00d878;
--green-bright:#3eff8a;
--green-dim: #0a6b3a;
--amber: #ffb020;
--amber-dim: #a06800;
--blue-signal: #2090ff;
--blue-dim: #0a3060;
--red-alert: #ff3040;
--red-heart: #ff4060;
--text-primary: #e8ece0;
--text-secondary: rgba(232,236,224, 0.55);
--text-label: rgba(232,236,224, 0.4);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg-deep);
overflow: hidden;
font-family: 'Inter', -apple-system, sans-serif;
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
#observatory-canvas {
position: fixed;
top: 0; left: 0;
width: 100vw; height: 100vh;
}
/* ---- HUD Overlay ---- */
#hud {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
pointer-events: none;
z-index: 10;
}
/* ---- Brand ---- */
#brand {
position: absolute;
top: 24px; left: 28px;
}
#brand-logo {
font-family: 'Inter', sans-serif;
font-weight: 700;
font-size: 32px;
color: var(--text-primary);
letter-spacing: -0.5px;
text-shadow: 0 0 30px rgba(0, 216, 120, 0.3);
}
.pi {
color: var(--green-glow);
font-style: italic;
margin-right: 2px;
}
#brand-tagline {
font-size: 11px;
color: var(--text-secondary);
letter-spacing: 1.5px;
text-transform: uppercase;
margin-top: 2px;
}
/* ---- Status bar (top right) ---- */
#status-bar {
position: absolute;
top: 24px; right: 28px;
display: flex;
align-items: center;
gap: 12px;
}
#data-source-badge {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border-radius: 20px;
background: rgba(0, 216, 120, 0.1);
border: 1px solid rgba(0, 216, 120, 0.25);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 1px;
color: var(--green-glow);
}
.dot {
width: 7px; height: 7px;
border-radius: 50%;
display: inline-block;
}
.dot--demo { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
.dot--live { background: var(--green-glow); box-shadow: 0 0 6px var(--green-glow); animation: pulse-dot 2s infinite; }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
#scenario-area {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 14px;
border-radius: 20px;
background: rgba(255, 176, 32, 0.1);
border: 1px solid rgba(255, 176, 32, 0.25);
pointer-events: auto;
}
#autoplay-icon {
font-size: 10px;
color: var(--green-glow);
animation: pulse-dot 2s infinite;
}
#autoplay-icon.hidden { display: none; }
#scenario-quick-select {
background: none;
border: none;
padding: 0;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 0.5px;
color: var(--amber);
cursor: pointer;
outline: none;
}
#scenario-quick-select:hover,
#scenario-quick-select:focus { color: var(--green-glow); }
#scenario-quick-select option {
background: #0c1420;
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
padding: 4px 8px;
}
#fps-counter {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-secondary);
}
/* ---- Data Panels ---- */
.data-panel {
position: absolute;
width: 220px;
background: var(--bg-panel);
border: 1px solid var(--bg-panel-border);
border-radius: 12px;
padding: 16px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
pointer-events: auto;
}
.panel-header {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 600;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--text-label);
margin-bottom: 14px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
/* ---- Vitals Panel (left) ---- */
#panel-vitals {
left: 28px;
top: 50%;
transform: translateY(-50%);
}
.vital-row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 18px;
}
.vital-row:last-child { margin-bottom: 0; }
.vital-icon {
font-size: 20px;
line-height: 1;
margin-top: 2px;
width: 24px;
text-align: center;
}
.vital-row:nth-child(2) .vital-icon { color: var(--red-heart); }
.vital-row:nth-child(3) .vital-icon { color: var(--green-glow); }
.vital-row:nth-child(4) .vital-icon { color: var(--amber); }
.vital-data { flex: 1; }
.vital-label {
font-size: 10px;
color: var(--text-label);
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 3px;
}
.vital-value {
font-family: 'JetBrains Mono', monospace;
font-size: 26px;
font-weight: 600;
line-height: 1.1;
}
.vital-unit {
font-size: 12px;
font-weight: 400;
color: var(--text-secondary);
}
.vital-bar {
height: 3px;
background: rgba(255,255,255,0.06);
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.vital-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
}
.vital-bar--hr { background: var(--red-heart); width: 0%; }
.vital-bar--br { background: var(--green-glow); width: 0%; }
.vital-bar--conf { background: var(--amber); width: 0%; }
/* ---- Signal Panel (right) ---- */
#panel-signal {
right: 28px;
top: 50%;
transform: translateY(-50%);
}
.signal-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.signal-label {
font-size: 11px;
color: var(--text-label);
letter-spacing: 0.5px;
}
.signal-value {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 600;
color: var(--blue-signal);
}
#rssi-sparkline {
width: 100%;
height: 48px;
margin-top: 8px;
border-radius: 6px;
background: rgba(0,0,0,0.3);
}
/* Presence */
.presence-state {
text-align: center;
padding: 8px;
border-radius: 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
letter-spacing: 2px;
transition: all 0.5s ease;
}
.presence--absent {
background: rgba(255,255,255,0.03);
color: var(--text-label);
border: 1px solid rgba(255,255,255,0.05);
}
.presence--present {
background: rgba(0, 216, 120, 0.1);
color: var(--green-glow);
border: 1px solid rgba(0, 216, 120, 0.3);
box-shadow: 0 0 20px rgba(0, 216, 120, 0.1);
}
.presence--active {
background: rgba(255, 176, 32, 0.1);
color: var(--amber);
border: 1px solid rgba(255, 176, 32, 0.3);
box-shadow: 0 0 20px rgba(255, 176, 32, 0.1);
}
.fall-alert {
margin-top: 10px;
text-align: center;
padding: 8px;
border-radius: 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
font-weight: 700;
letter-spacing: 2px;
background: rgba(255, 48, 64, 0.15);
color: var(--red-alert);
border: 1px solid rgba(255, 48, 64, 0.4);
animation: pulse-alert 0.8s infinite;
}
@keyframes pulse-alert {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ---- Capabilities Bar (bottom center) ---- */
#capabilities-bar {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0;
background: var(--bg-panel);
border: 1px solid var(--bg-panel-border);
border-radius: 30px;
padding: 8px 24px;
backdrop-filter: blur(12px);
}
.cap-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
padding: 0 16px;
}
.cap-icon {
font-size: 16px;
color: var(--green-glow);
}
.cap-item:nth-child(3) .cap-icon { color: var(--red-heart); }
.cap-item:nth-child(5) .cap-icon { color: var(--blue-signal); }
.cap-divider {
width: 1px;
height: 20px;
background: rgba(255,255,255,0.1);
}
/* ---- Key hints ---- */
#key-hints {
position: absolute;
bottom: 24px;
right: 28px;
display: flex;
gap: 8px;
}
.key-hint {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: rgba(255,255,255,0.2);
letter-spacing: 0.5px;
padding: 3px 8px;
border-radius: 4px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.05);
}
/* ---- Settings button ---- */
#settings-btn {
pointer-events: auto;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
color: var(--text-secondary);
font-size: 18px;
width: 34px; height: 34px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s;
display: flex; align-items: center; justify-content: center;
padding: 0;
}
#settings-btn:hover {
background: rgba(0, 216, 120, 0.15);
border-color: var(--green-glow);
color: var(--green-glow);
}
/* ---- Settings Dialog ---- */
.settings-overlay {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 100;
background: rgba(0,0,0,0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
}
.settings-dialog {
background: rgba(10, 16, 28, 0.96);
border: 1px solid rgba(0, 216, 120, 0.2);
border-radius: 16px;
width: 440px;
max-height: 80vh;
overflow-y: auto;
padding: 0;
box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 40px rgba(0,216,120,0.05);
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid rgba(255,255,255,0.06);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-primary);
}
.settings-header button {
background: none;
border: none;
color: var(--text-secondary);
font-size: 22px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.settings-header button:hover { color: var(--red-alert); }
.settings-tabs {
display: flex;
border-bottom: 1px solid rgba(255,255,255,0.06);
padding: 0 12px;
}
.stab {
background: none;
border: none;
color: var(--text-label);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
letter-spacing: 1px;
text-transform: uppercase;
padding: 10px 14px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.stab:hover { color: var(--text-secondary); }
.stab.active {
color: var(--green-glow);
border-bottom-color: var(--green-glow);
}
.stab-content {
display: none;
padding: 16px 20px;
}
.stab-content.active { display: block; }
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
font-size: 12px;
color: var(--text-secondary);
}
.setting-row span:first-child {
min-width: 120px;
flex-shrink: 0;
}
.setting-row input[type="range"] {
flex: 1;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255,255,255,0.08);
border-radius: 2px;
outline: none;
}
.setting-row input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
border-radius: 50%;
background: var(--green-glow);
cursor: pointer;
box-shadow: 0 0 6px rgba(0,216,120,0.4);
}
.setting-row input[type="color"] {
-webkit-appearance: none;
width: 36px; height: 24px;
border: 1px solid rgba(255,255,255,0.15);
border-radius: 4px;
background: none;
cursor: pointer;
padding: 0;
}
.setting-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 2px; }
.setting-row input[type="color"]::-webkit-color-swatch { border-radius: 2px; border: none; }
.setting-row select,
.setting-row input[type="text"] {
flex: 1;
background: #0c1420;
border: 1px solid rgba(255,255,255,0.1);
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
padding: 6px 10px;
border-radius: 6px;
outline: none;
}
.setting-row select:focus,
.setting-row input[type="text"]:focus {
border-color: var(--green-glow);
}
.setting-row select option {
background: #0c1420;
color: var(--text-primary);
padding: 6px 10px;
}
.setting-row select optgroup {
background: #0a1018;
color: var(--green-glow);
font-style: normal;
font-weight: 600;
padding: 4px 0;
}
.setting-row input[type="checkbox"] {
width: 18px; height: 18px;
accent-color: var(--green-glow);
cursor: pointer;
}
.check-row {
flex-direction: row;
}
.range-val {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--green-glow);
min-width: 44px;
text-align: right;
}
.settings-btn {
width: 100%;
padding: 8px;
margin-top: 6px;
background: rgba(0, 216, 120, 0.08);
border: 1px solid rgba(0, 216, 120, 0.2);
color: var(--green-glow);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 1px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.settings-btn:hover {
background: rgba(0, 216, 120, 0.15);
border-color: var(--green-glow);
}
/* ---- Scenario Description ---- */
#scenario-description {
position: absolute;
top: 60px;
right: 28px;
max-width: 340px;
font-size: 11px;
color: var(--text-secondary);
font-style: italic;
letter-spacing: 0.3px;
line-height: 1.4;
pointer-events: none;
opacity: 0.7;
transition: opacity 0.5s ease;
}
/* ---- Edge Module Badges ---- */
#edge-modules-bar {
position: absolute;
bottom: 58px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 6px;
pointer-events: none;
}
.edge-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-family: 'JetBrains Mono', monospace;
font-size: 9px;
font-weight: 600;
letter-spacing: 1px;
color: var(--badge-color, var(--text-secondary));
background: rgba(255,255,255,0.04);
border: 1px solid var(--badge-color, rgba(255,255,255,0.1));
box-shadow: 0 0 6px color-mix(in srgb, var(--badge-color, transparent) 30%, transparent);
}
/* ---- Person Count Dots ---- */
.persons-dots {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
vertical-align: middle;
}
.person-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.1);
transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease;
}
.person-dot--active {
background: var(--green-glow);
border-color: var(--green-glow);
box-shadow: 0 0 4px rgba(0, 216, 120, 0.4);
}
/* ---- Vital Value Color Transitions ---- */
.vital-value span:first-child {
transition: color 0.6s ease;
}
.vital-bar-fill {
transition: width 0.5s ease, background 0.6s ease;
}
/* ---- Responsive ---- */
@media (max-width: 1200px) {
.data-panel { width: 190px; padding: 12px; }
.vital-value { font-size: 22px; }
#capabilities-bar { display: none; }
}
@media (max-width: 800px) {
.data-panel { display: none; }
#key-hints { display: none; }
.settings-dialog { width: 95vw; }
}

View File

@ -1,221 +0,0 @@
/**
* 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();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,121 +0,0 @@
/**
* Holographic Panel Reusable frame with border shader, scan line, title
*/
import * as THREE from 'three';
const BORDER_VERTEX = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const BORDER_FRAGMENT = `
uniform float uTime;
uniform vec3 uColor;
varying vec2 vUv;
void main() {
// Thin border
float bx = step(vUv.x, 0.015) + step(1.0 - 0.015, vUv.x);
float by = step(vUv.y, 0.02) + step(1.0 - 0.02, vUv.y);
float border = clamp(bx + by, 0.0, 1.0);
// Scan line moving upward
float scan = smoothstep(0.0, 0.02, abs(vUv.y - fract(uTime * 0.15))) ;
scan = 1.0 - (1.0 - scan) * 0.4;
// Corner accents
float corner = 0.0;
float cx = min(vUv.x, 1.0 - vUv.x);
float cy = min(vUv.y, 1.0 - vUv.y);
if (cx < 0.06 && cy < 0.08) corner = 0.6;
// Subtle fill
float fill = 0.03 + corner * 0.05;
float alpha = max(border * 0.7, fill) * scan;
gl_FragColor = vec4(uColor, alpha);
}
`;
export class HolographicPanel {
/**
* @param {Object} opts
* @param {number[]} opts.position - [x, y, z]
* @param {number} opts.width
* @param {number} opts.height
* @param {string} opts.title
* @param {number} [opts.color=0x00d4ff]
*/
constructor(opts) {
this.group = new THREE.Group();
this.group.position.set(...opts.position);
const color = new THREE.Color(opts.color || 0x00d4ff);
// Border plane
this._uniforms = {
uTime: { value: 0 },
uColor: { value: color },
};
const borderGeo = new THREE.PlaneGeometry(opts.width, opts.height);
const borderMat = new THREE.ShaderMaterial({
vertexShader: BORDER_VERTEX,
fragmentShader: BORDER_FRAGMENT,
uniforms: this._uniforms,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
this._border = new THREE.Mesh(borderGeo, borderMat);
this.group.add(this._border);
// Title sprite
if (opts.title) {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'transparent';
ctx.fillRect(0, 0, 512, 64);
ctx.font = '600 28px "Courier New", monospace';
ctx.fillStyle = `#${color.getHexString()}`;
ctx.textAlign = 'center';
ctx.fillText(opts.title.toUpperCase(), 256, 42);
const tex = new THREE.CanvasTexture(canvas);
const spriteMat = new THREE.SpriteMaterial({
map: tex,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const sprite = new THREE.Sprite(spriteMat);
sprite.scale.set(opts.width * 0.8, opts.width * 0.1, 1);
sprite.position.y = opts.height / 2 + 0.3;
this.group.add(sprite);
this._titleSprite = sprite;
this._titleTex = tex;
}
}
update(dt, elapsed) {
this._uniforms.uTime.value = elapsed;
}
/** Make panel face camera */
lookAt(cameraPos) {
this.group.lookAt(cameraPos);
}
dispose() {
this._border.geometry.dispose();
this._border.material.dispose();
if (this._titleTex) this._titleTex.dispose();
if (this._titleSprite) this._titleSprite.material.dispose();
}
}

View File

@ -1,567 +0,0 @@
/**
* HudController Extracted HUD update, settings dialog, and scenario UI
*
* Manages all DOM-based HUD elements:
* - Vital sign display with smooth lerp transitions and color coding
* - Signal metrics, sparkline, and presence indicator
* - Scenario description and edge module badges
* - Mini person-count dot visualization
* - Settings dialog (tabs, ranges, presets, data source)
* - Quick-select scenario dropdown
*/
// ---- Constants ----
export const SCENARIO_NAMES = [
'EMPTY ROOM','VITAL SIGNS','MULTI-PERSON','FALL DETECT',
'SLEEP MONITOR','INTRUSION','GESTURE CTRL','CROWD OCCUPANCY',
'SEARCH RESCUE','ELDERLY CARE','FITNESS','SECURITY PATROL',
];
export const DEFAULTS = {
bloom: 0.08, bloomRadius: 0.2, bloomThresh: 0.6,
exposure: 1.3, vignette: 0.25, grain: 0.01, chromatic: 0.0005,
boneThick: 0.018, jointSize: 0.035, glow: 0.3, trail: 0.35,
wireColor: '#00d878', jointColor: '#ff4060', aura: 0.02,
field: 0.45, waves: 0.4, ambient: 0.7, reflect: 0.2,
fov: 50, orbitSpeed: 0.15, grid: true, room: true,
scenario: 'auto', cycle: 30, dataSource: 'demo', wsUrl: '',
};
export const SETTINGS_VERSION = '6';
export const PRESETS = {
foundation: {},
cinematic: {
bloom: 1.2, bloomRadius: 0.5, bloomThresh: 0.2,
exposure: 0.8, vignette: 0.7, grain: 0.04, chromatic: 0.002,
glow: 0.6, trail: 0.8, aura: 0.06, field: 0.4,
waves: 0.7, ambient: 0.25, reflect: 0.5, fov: 40, orbitSpeed: 0.08,
},
minimal: {
bloom: 0.3, bloomRadius: 0.2, bloomThresh: 0.5,
exposure: 1.1, vignette: 0.2, grain: 0, chromatic: 0,
glow: 0.3, trail: 0.2, aura: 0.02, field: 0.7,
waves: 0.3, ambient: 0.6, reflect: 0.1, wireColor: '#40ff90', jointColor: '#4080ff',
},
neon: {
bloom: 2.5, bloomRadius: 0.8, bloomThresh: 0.1,
exposure: 0.6, vignette: 0.6, grain: 0.02, chromatic: 0.004,
glow: 2.0, trail: 1.0, aura: 0.15, field: 0.6,
waves: 1.0, ambient: 0.15, reflect: 0.7, wireColor: '#00ffaa', jointColor: '#ff00ff',
},
tactical: {
bloom: 0.5, bloomRadius: 0.3, bloomThresh: 0.4,
exposure: 0.85, vignette: 0.4, grain: 0.04, chromatic: 0.001,
glow: 0.5, trail: 0.4, aura: 0.03, field: 0.8,
waves: 0.4, ambient: 0.3, reflect: 0.15, wireColor: '#30ff60', jointColor: '#ff8800',
},
medical: {
bloom: 0.6, bloomRadius: 0.4, bloomThresh: 0.35,
exposure: 1.0, vignette: 0.3, grain: 0.01, chromatic: 0.0005,
glow: 0.6, trail: 0.3, aura: 0.04, field: 0.5,
waves: 0.3, ambient: 0.5, reflect: 0.2, wireColor: '#00ccff', jointColor: '#ff3355',
},
};
// Scenario descriptions shown below the dropdown
const SCENARIO_DESCRIPTIONS = {
auto: 'Auto-cycling through all sensing scenarios.',
empty_room: 'Baseline calibration with no human presence in the monitored zone.',
single_breathing: 'Detecting vital signs through WiFi signal micro-variations.',
two_walking: 'Tracking multiple people simultaneously via CSI multiplex separation.',
fall_event: 'Sudden posture-change detection using acceleration feature analysis.',
sleep_monitoring: 'Monitoring breathing patterns and apnea events during sleep.',
intrusion_detect: 'Passive perimeter monitoring -- no cameras, pure RF sensing.',
gesture_control: 'DTW-based gesture recognition from hand/arm motion signatures.',
crowd_occupancy: 'Estimating room occupancy count from aggregate CSI variance.',
search_rescue: 'Through-wall survivor detection using WiFi-MAT multistatic mode.',
elderly_care: 'Continuous gait analysis for early mobility-decline detection.',
fitness_tracking: 'Rep counting and exercise classification from body kinematics.',
security_patrol: 'Multi-zone presence patrol with camera-free motion heatmaps.',
};
// Edge modules active per scenario
const SCENARIO_EDGE_MODULES = {
auto: [],
empty_room: [],
single_breathing: ['VITALS'],
two_walking: ['GAIT', 'TRACKING'],
fall_event: ['FALL', 'VITALS'],
sleep_monitoring: ['VITALS', 'APNEA'],
intrusion_detect: ['PRESENCE', 'ALERT'],
gesture_control: ['GESTURE', 'DTW'],
crowd_occupancy: ['OCCUPANCY'],
search_rescue: ['MAT', 'VITALS', 'PRESENCE'],
elderly_care: ['GAIT', 'VITALS', 'FALL'],
fitness_tracking: ['GESTURE', 'GAIT'],
security_patrol: ['PRESENCE', 'ALERT', 'TRACKING'],
};
// Edge-module badge colors
const MODULE_COLORS = {
VITALS: 'var(--red-heart)',
GAIT: 'var(--green-glow)',
FALL: 'var(--red-alert)',
GESTURE: 'var(--amber)',
PRESENCE: 'var(--blue-signal)',
TRACKING: 'var(--green-bright)',
OCCUPANCY: 'var(--amber)',
ALERT: 'var(--red-alert)',
DTW: 'var(--amber)',
APNEA: 'var(--red-heart)',
MAT: 'var(--blue-signal)',
};
// Vital-sign color-coding thresholds
function vitalColor(type, value) {
if (value <= 0) return 'var(--text-secondary)';
if (type === 'hr') {
if (value < 50 || value > 130) return 'var(--red-alert)';
if (value < 60 || value > 100) return 'var(--amber)';
return 'var(--green-glow)';
}
if (type === 'br') {
if (value < 8 || value > 28) return 'var(--red-alert)';
if (value < 12 || value > 20) return 'var(--amber)';
return 'var(--green-glow)';
}
if (type === 'conf') {
if (value < 40) return 'var(--red-alert)';
if (value < 70) return 'var(--amber)';
return 'var(--green-glow)';
}
return 'var(--text-primary)';
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
// ---- HudController class ----
export class HudController {
constructor(observatory) {
this._obs = observatory;
this._settingsOpen = false;
this._rssiHistory = [];
this._sparklineCtx = document.getElementById('rssi-sparkline')?.getContext('2d');
// Lerp state for smooth vital-sign transitions
this._lerpHr = 0;
this._lerpBr = 0;
this._lerpConf = 0;
// Track current scenario for description/edge updates
this._currentScenarioKey = null;
}
// ============================================================
// Settings dialog
// ============================================================
initSettings() {
const overlay = document.getElementById('settings-overlay');
const btn = document.getElementById('settings-btn');
const closeBtn = document.getElementById('settings-close');
btn.addEventListener('click', () => this.toggleSettings());
closeBtn.addEventListener('click', () => this.toggleSettings());
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.toggleSettings(); });
// Tab switching
document.querySelectorAll('.stab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.stab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.stab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById(`stab-${tab.dataset.stab}`).classList.add('active');
});
});
const obs = this._obs;
const s = obs.settings;
// Bind ranges
this._bindRange('opt-bloom', 'bloom', v => { obs._postProcessing._bloomPass.strength = v; });
this._bindRange('opt-bloom-radius', 'bloomRadius', v => { obs._postProcessing._bloomPass.radius = v; });
this._bindRange('opt-bloom-thresh', 'bloomThresh', v => { obs._postProcessing._bloomPass.threshold = v; });
this._bindRange('opt-exposure', 'exposure', v => { obs._renderer.toneMappingExposure = v; });
this._bindRange('opt-vignette', 'vignette', v => { obs._postProcessing._vignettePass.uniforms.uVignetteStrength.value = v; });
this._bindRange('opt-grain', 'grain', v => { obs._postProcessing._vignettePass.uniforms.uGrainStrength.value = v; });
this._bindRange('opt-chromatic', 'chromatic', v => { obs._postProcessing._vignettePass.uniforms.uChromaticStrength.value = v; });
this._bindRange('opt-bone-thick', 'boneThick');
this._bindRange('opt-joint-size', 'jointSize');
this._bindRange('opt-glow', 'glow');
this._bindRange('opt-trail', 'trail');
this._bindRange('opt-aura', 'aura');
this._bindRange('opt-field', 'field', v => { obs._fieldMat.opacity = v; });
this._bindRange('opt-waves', 'waves');
this._bindRange('opt-ambient', 'ambient', v => { obs._ambient.intensity = v * 5.0; });
this._bindRange('opt-reflect', 'reflect', v => {
obs._floorMat.roughness = 1.0 - v * 0.7;
obs._floorMat.metalness = v * 0.5;
});
this._bindRange('opt-fov', 'fov', v => {
obs._camera.fov = v;
obs._camera.updateProjectionMatrix();
});
this._bindRange('opt-orbit-speed', 'orbitSpeed');
this._bindRange('opt-cycle', 'cycle', v => { obs._demoData.setCycleDuration(v); });
// Color pickers
document.getElementById('opt-wire-color').value = s.wireColor;
document.getElementById('opt-wire-color').addEventListener('input', (e) => {
s.wireColor = e.target.value; obs._applyColors(); this.saveSettings();
});
document.getElementById('opt-joint-color').value = s.jointColor;
document.getElementById('opt-joint-color').addEventListener('input', (e) => {
s.jointColor = e.target.value; obs._applyColors(); this.saveSettings();
});
// Checkboxes
document.getElementById('opt-grid').checked = s.grid;
document.getElementById('opt-grid').addEventListener('change', (e) => {
s.grid = e.target.checked; obs._grid.visible = e.target.checked; this.saveSettings();
});
document.getElementById('opt-room').checked = s.room;
document.getElementById('opt-room').addEventListener('change', (e) => {
s.room = e.target.checked; obs._roomWire.visible = e.target.checked; this.saveSettings();
});
// Scenario select
const scenarioSel = document.getElementById('opt-scenario');
scenarioSel.value = s.scenario;
scenarioSel.addEventListener('change', (e) => {
s.scenario = e.target.value;
obs._demoData.setScenario(e.target.value);
this.saveSettings();
});
// Data source
const dsSel = document.getElementById('opt-data-source');
dsSel.value = s.dataSource;
dsSel.addEventListener('change', (e) => {
s.dataSource = e.target.value;
document.getElementById('ws-url-row').style.display = e.target.value === 'ws' ? 'flex' : 'none';
if (e.target.value === 'ws' && s.wsUrl) obs._connectWS(s.wsUrl);
else obs._disconnectWS();
this.updateSourceBadge(s.dataSource, obs._ws);
this.saveSettings();
});
document.getElementById('ws-url-row').style.display = s.dataSource === 'ws' ? 'flex' : 'none';
const wsInput = document.getElementById('opt-ws-url');
wsInput.value = s.wsUrl;
wsInput.addEventListener('change', (e) => {
s.wsUrl = e.target.value;
if (s.dataSource === 'ws') obs._connectWS(e.target.value);
this.saveSettings();
});
// Buttons
document.getElementById('btn-reset-camera').addEventListener('click', () => {
obs._camera.position.set(6, 5, 8);
obs._controls.target.set(0, 1.2, 0);
obs._controls.update();
});
document.getElementById('btn-export-settings').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(s, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'ruview-observatory-settings.json';
a.click();
});
document.getElementById('btn-reset-settings').addEventListener('click', () => {
this.applyPreset(DEFAULTS);
});
const presetSel = document.getElementById('opt-preset');
presetSel.addEventListener('change', (e) => {
const p = PRESETS[e.target.value];
if (p) this.applyPreset({ ...DEFAULTS, ...p });
});
obs._grid.visible = s.grid;
obs._roomWire.visible = s.room;
}
// ============================================================
// Quick-select (top bar scenario dropdown)
// ============================================================
initQuickSelect() {
const sel = document.getElementById('scenario-quick-select');
if (!sel) return;
sel.addEventListener('change', (e) => {
this._obs._demoData.setScenario(e.target.value);
const settingsSel = document.getElementById('opt-scenario');
if (settingsSel) settingsSel.value = e.target.value;
this._obs.settings.scenario = e.target.value;
this.saveSettings();
});
}
// ============================================================
// Toggle / save / preset
// ============================================================
toggleSettings() {
this._settingsOpen = !this._settingsOpen;
document.getElementById('settings-overlay').style.display = this._settingsOpen ? 'flex' : 'none';
}
get settingsOpen() {
return this._settingsOpen;
}
saveSettings() {
try {
localStorage.setItem('ruview-observatory-settings', JSON.stringify(this._obs.settings));
} catch {}
}
applyPreset(preset) {
const obs = this._obs;
Object.assign(obs.settings, preset);
this.saveSettings();
const rangeMap = {
'opt-bloom': 'bloom', 'opt-bloom-radius': 'bloomRadius', 'opt-bloom-thresh': 'bloomThresh',
'opt-exposure': 'exposure', 'opt-vignette': 'vignette', 'opt-grain': 'grain', 'opt-chromatic': 'chromatic',
'opt-bone-thick': 'boneThick', 'opt-joint-size': 'jointSize', 'opt-glow': 'glow', 'opt-trail': 'trail', 'opt-aura': 'aura',
'opt-field': 'field', 'opt-waves': 'waves', 'opt-ambient': 'ambient', 'opt-reflect': 'reflect',
'opt-fov': 'fov', 'opt-orbit-speed': 'orbitSpeed', 'opt-cycle': 'cycle',
};
for (const [id, key] of Object.entries(rangeMap)) {
const el = document.getElementById(id);
const valEl = document.getElementById(`${id}-val`);
if (el) el.value = obs.settings[key];
if (valEl) valEl.textContent = obs.settings[key];
}
const gridEl = document.getElementById('opt-grid');
if (gridEl) { gridEl.checked = obs.settings.grid; obs._grid.visible = obs.settings.grid; }
const roomEl = document.getElementById('opt-room');
if (roomEl) { roomEl.checked = obs.settings.room; obs._roomWire.visible = obs.settings.room; }
document.getElementById('opt-wire-color').value = obs.settings.wireColor;
document.getElementById('opt-joint-color').value = obs.settings.jointColor;
obs._applyPostSettings();
obs._renderer.toneMappingExposure = obs.settings.exposure;
obs._fieldMat.opacity = obs.settings.field;
obs._ambient.intensity = obs.settings.ambient * 5.0;
obs._floorMat.roughness = 1.0 - obs.settings.reflect * 0.7;
obs._floorMat.metalness = obs.settings.reflect * 0.5;
obs._camera.fov = obs.settings.fov;
obs._camera.updateProjectionMatrix();
obs._demoData.setCycleDuration(obs.settings.cycle);
obs._applyColors();
}
// ============================================================
// Source badge
// ============================================================
updateSourceBadge(dataSource, ws) {
const dot = document.querySelector('#data-source-badge .dot');
const label = document.getElementById('data-source-label');
if (dataSource === 'ws' && ws?.readyState === WebSocket.OPEN) {
dot.className = 'dot dot--live'; label.textContent = 'LIVE';
} else {
dot.className = 'dot dot--demo'; label.textContent = 'DEMO';
}
}
// ============================================================
// HUD update (called every frame)
// ============================================================
updateHUD(data, demoData) {
if (!data) return;
const vs = data.vital_signs || {};
const feat = data.features || {};
const cls = data.classification || {};
// Sync scenario dropdown
const quickSel = document.getElementById('scenario-quick-select');
const cur = demoData._autoMode ? 'auto' : demoData.currentScenario;
if (quickSel && quickSel.value !== cur) quickSel.value = cur;
const autoIcon = document.getElementById('autoplay-icon');
if (autoIcon) autoIcon.className = demoData._autoMode ? '' : 'hidden';
const targetHr = vs.heart_rate_bpm || 0;
const targetBr = vs.breathing_rate_bpm || 0;
const targetConf = Math.round((cls.confidence || 0) * 100);
// Smooth lerp transitions (blend 4% per frame toward target — very stable)
const lerpFactor = 0.04;
this._lerpHr = targetHr > 0 ? lerp(this._lerpHr, targetHr, lerpFactor) : 0;
this._lerpBr = targetBr > 0 ? lerp(this._lerpBr, targetBr, lerpFactor) : 0;
this._lerpConf = targetConf > 0 ? lerp(this._lerpConf, targetConf, lerpFactor) : 0;
const dispHr = this._lerpHr > 1 ? Math.round(this._lerpHr) : '--';
const dispBr = this._lerpBr > 1 ? Math.round(this._lerpBr) : '--';
const dispConf = this._lerpConf > 1 ? Math.round(this._lerpConf) : '--';
this._setText('hr-value', dispHr);
this._setText('br-value', dispBr);
this._setText('conf-value', dispConf);
this._setWidth('hr-bar', Math.min(100, this._lerpHr / 120 * 100));
this._setWidth('br-bar', Math.min(100, this._lerpBr / 30 * 100));
this._setWidth('conf-bar', this._lerpConf);
// Color-code vital values
this._setColor('hr-value', vitalColor('hr', this._lerpHr));
this._setColor('br-value', vitalColor('br', this._lerpBr));
this._setColor('conf-value', vitalColor('conf', this._lerpConf));
// Color-code bar fills to match
this._setBarColor('hr-bar', vitalColor('hr', this._lerpHr));
this._setBarColor('br-bar', vitalColor('br', this._lerpBr));
this._setBarColor('conf-bar', vitalColor('conf', this._lerpConf));
this._setText('rssi-value', `${Math.round(feat.mean_rssi || 0)} dBm`);
this._setText('var-value', (feat.variance || 0).toFixed(2));
this._setText('motion-value', (feat.motion_band_power || 0).toFixed(3));
// Mini person-count dots
const personCount = data.estimated_persons || 0;
this._updatePersonDots(personCount);
const presEl = document.getElementById('presence-indicator');
const presLabel = document.getElementById('presence-label');
if (presEl) {
const ml = cls.motion_level || 'absent';
presEl.className = 'presence-state';
if (ml === 'active') { presEl.classList.add('presence--active'); presLabel.textContent = 'ACTIVE'; }
else if (cls.presence) { presEl.classList.add('presence--present'); presLabel.textContent = 'PRESENT'; }
else { presEl.classList.add('presence--absent'); presLabel.textContent = 'ABSENT'; }
}
const fallEl = document.getElementById('fall-alert');
if (fallEl) fallEl.style.display = cls.fall_detected ? 'block' : 'none';
// Scenario description and edge modules
const scenarioKey = demoData._autoMode ? (demoData.currentScenario || 'auto') : (demoData.currentScenario || 'auto');
if (scenarioKey !== this._currentScenarioKey) {
this._currentScenarioKey = scenarioKey;
this._updateScenarioDescription(scenarioKey);
this._updateEdgeModules(scenarioKey);
}
}
// ============================================================
// Sparkline
// ============================================================
updateSparkline(data) {
const rssi = data?.features?.mean_rssi;
if (rssi == null || !this._sparklineCtx) return;
this._rssiHistory.push(rssi);
if (this._rssiHistory.length > 60) this._rssiHistory.shift();
const ctx = this._sparklineCtx;
const w = ctx.canvas.width, h = ctx.canvas.height;
ctx.clearRect(0, 0, w, h);
if (this._rssiHistory.length < 2) return;
ctx.beginPath();
ctx.strokeStyle = '#2090ff';
ctx.lineWidth = 1.5;
ctx.shadowColor = '#2090ff';
ctx.shadowBlur = 4;
for (let i = 0; i < this._rssiHistory.length; i++) {
const x = (i / (this._rssiHistory.length - 1)) * w;
const norm = Math.max(0, Math.min(1, (this._rssiHistory[i] + 80) / 60));
const y = h - norm * h;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
ctx.lineTo(w, h);
ctx.lineTo(0, h);
ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, 'rgba(32,144,255,0.15)');
grad.addColorStop(1, 'rgba(32,144,255,0)');
ctx.fillStyle = grad;
ctx.fill();
}
// ============================================================
// Private helpers
// ============================================================
_setText(id, val) {
const e = document.getElementById(id);
if (e) e.textContent = val;
}
_setWidth(id, pct) {
const e = document.getElementById(id);
if (e) e.style.width = `${pct}%`;
}
_setColor(id, color) {
const e = document.getElementById(id);
if (e) e.style.color = color;
}
_setBarColor(id, color) {
const e = document.getElementById(id);
if (e) e.style.background = color;
}
_bindRange(id, key, applyFn) {
const el = document.getElementById(id);
const valEl = document.getElementById(`${id}-val`);
if (!el) return;
el.value = this._obs.settings[key];
if (valEl) valEl.textContent = this._obs.settings[key];
el.addEventListener('input', (e) => {
const v = parseFloat(e.target.value);
this._obs.settings[key] = v;
if (valEl) valEl.textContent = v;
if (applyFn) applyFn(v);
this.saveSettings();
});
}
_updatePersonDots(count) {
const container = document.getElementById('persons-dots');
if (!container) {
// Fall back to text-only display
this._setText('persons-value', count);
return;
}
// Build dot icons: filled for detected persons, dim for empty slots (max 8)
const maxDots = 8;
const clamped = Math.min(count, maxDots);
let html = '';
for (let i = 0; i < maxDots; i++) {
const active = i < clamped;
html += `<span class="person-dot${active ? ' person-dot--active' : ''}"></span>`;
}
container.innerHTML = html;
this._setText('persons-value', count);
}
_updateScenarioDescription(scenarioKey) {
const el = document.getElementById('scenario-description');
if (!el) return;
el.textContent = SCENARIO_DESCRIPTIONS[scenarioKey] || '';
}
_updateEdgeModules(scenarioKey) {
const bar = document.getElementById('edge-modules-bar');
if (!bar) return;
const modules = SCENARIO_EDGE_MODULES[scenarioKey] || [];
if (modules.length === 0) {
bar.innerHTML = '';
bar.style.display = 'none';
return;
}
bar.style.display = 'flex';
bar.innerHTML = modules.map(m => {
const color = MODULE_COLORS[m] || 'var(--text-secondary)';
return `<span class="edge-badge" style="--badge-color:${color}">${m}</span>`;
}).join('');
}
}

View File

@ -1,715 +0,0 @@
/**
* RuView Observatory Main Scene Orchestrator
*
* Room-based WiFi sensing visualization with:
* - Pool of 4 human wireframe figures (multi-person scenarios)
* - 7 pose types (standing, walking, lying, sitting, fallen, exercising, gesturing, crouching)
* - Scenario-specific room props (chair, exercise mat, door, rubble wall, screen, desk)
* - Dot-matrix mist body mass, particle trails, WiFi waves, signal field
* - Reflective floor, settings dialog, and practical data HUD
*/
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { DemoDataGenerator } from './demo-data.js';
import { NebulaBackground } from './nebula-background.js';
import { PostProcessing } from './post-processing.js';
import { FigurePool, SKELETON_PAIRS } from './figure-pool.js';
import { PoseSystem } from './pose-system.js';
import { ScenarioProps } from './scenario-props.js';
import { HudController, DEFAULTS, SETTINGS_VERSION, PRESETS, SCENARIO_NAMES } from './hud-controller.js';
// ---- Palette ----
const C = {
greenGlow: 0x00d878,
greenBright:0x3eff8a,
greenDim: 0x0a6b3a,
amber: 0xffb020,
blueSignal: 0x2090ff,
redAlert: 0xff3040,
redHeart: 0xff4060,
bgDeep: 0x080c14,
};
// SCENARIO_NAMES, DEFAULTS, SETTINGS_VERSION, PRESETS imported from hud-controller.js
// ---- Main Class ----
class Observatory {
constructor() {
this._canvas = document.getElementById('observatory-canvas');
this.settings = { ...DEFAULTS };
// Load saved settings
try {
const ver = localStorage.getItem('ruview-settings-version');
if (ver === SETTINGS_VERSION) {
const saved = localStorage.getItem('ruview-observatory-settings');
if (saved) Object.assign(this.settings, JSON.parse(saved));
} else {
localStorage.removeItem('ruview-observatory-settings');
localStorage.setItem('ruview-settings-version', SETTINGS_VERSION);
}
} catch {}
// Renderer
this._renderer = new THREE.WebGLRenderer({
canvas: this._canvas,
antialias: true,
powerPreference: 'high-performance',
});
this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this._renderer.setSize(window.innerWidth, window.innerHeight);
this._renderer.toneMapping = THREE.ACESFilmicToneMapping;
this._renderer.toneMappingExposure = this.settings.exposure;
this._renderer.shadowMap.enabled = true;
this._renderer.shadowMap.type = THREE.PCFSoftShadowMap;
// Scene
this._scene = new THREE.Scene();
this._scene.background = new THREE.Color(C.bgDeep);
this._scene.fog = new THREE.FogExp2(C.bgDeep, 0.005);
// Camera
this._camera = new THREE.PerspectiveCamera(
this.settings.fov, window.innerWidth / window.innerHeight, 0.1, 300
);
this._camera.position.set(6, 5, 8);
this._camera.lookAt(0, 1.2, 0);
// Controls
this._controls = new OrbitControls(this._camera, this._canvas);
this._controls.enableDamping = true;
this._controls.dampingFactor = 0.08;
this._controls.minDistance = 2;
this._controls.maxDistance = 25;
this._controls.maxPolarAngle = Math.PI * 0.88;
this._controls.target.set(0, 1.2, 0);
this._controls.update();
this._clock = new THREE.Clock();
// Data
this._demoData = new DemoDataGenerator();
this._demoData.setCycleDuration(this.settings.cycle || 30);
if (this.settings.scenario && this.settings.scenario !== 'auto') {
this._demoData.setScenario(this.settings.scenario);
}
this._currentData = null;
this._currentScenario = null;
// Build scene
this._setupLighting();
this._nebula = new NebulaBackground(this._scene);
this._buildRoom();
this._buildRouter();
this._poseSystem = new PoseSystem();
this._figurePool = new FigurePool(this._scene, this.settings, this._poseSystem);
this._scenarioProps = new ScenarioProps(this._scene);
this._buildDotMatrixMist();
this._buildParticleTrail();
this._buildWifiWaves();
this._buildSignalField();
// Post-processing
this._postProcessing = new PostProcessing(this._renderer, this._scene, this._camera);
this._applyPostSettings();
// HUD controller (settings dialog, sparkline, vital displays)
this._hud = new HudController(this);
// State
this._autopilot = false;
this._autoAngle = 0;
this._fpsFrames = 0;
this._fpsTime = 0;
this._fpsValue = 60;
this._showFps = false;
this._qualityLevel = 2;
// WebSocket for live data — always try auto-detect on startup
this._ws = null;
this._liveData = null;
this._autoDetectLive();
// Input
this._initKeyboard();
this._hud.initSettings();
this._hud.initQuickSelect();
window.addEventListener('resize', () => this._onResize());
// Start
this._animate();
}
// ---- Lighting ----
_setupLighting() {
this._ambient = new THREE.AmbientLight(0xccccdd, this.settings.ambient * 5.0);
this._scene.add(this._ambient);
const hemi = new THREE.HemisphereLight(0x6688bb, 0x203040, 1.2);
this._scene.add(hemi);
const key = new THREE.DirectionalLight(0xffeedd, 1.2);
key.position.set(4, 8, 3);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.camera.near = 0.5;
key.shadow.camera.far = 20;
key.shadow.camera.left = -8;
key.shadow.camera.right = 8;
key.shadow.camera.top = 8;
key.shadow.camera.bottom = -8;
this._scene.add(key);
// Fill light from opposite side
const fill = new THREE.DirectionalLight(0x8899bb, 0.7);
fill.position.set(-4, 5, -2);
this._scene.add(fill);
// Rim light from above/behind for edge definition
const rim = new THREE.DirectionalLight(0x6699cc, 0.5);
rim.position.set(0, 6, -5);
this._scene.add(rim);
// Overhead room light — general illumination
const overhead = new THREE.PointLight(0x8899aa, 1.0, 20, 1.0);
overhead.position.set(0, 3.8, 0);
this._scene.add(overhead);
}
// ---- Room ----
_buildRoom() {
this._grid = new THREE.GridHelper(12, 24, 0x1a4830, 0x0c2818);
this._grid.material.opacity = 0.5;
this._grid.material.transparent = true;
this._scene.add(this._grid);
const boxGeo = new THREE.BoxGeometry(12, 4, 10);
const edges = new THREE.EdgesGeometry(boxGeo);
this._roomWire = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({
color: C.greenDim, opacity: 0.3, transparent: true,
}));
this._roomWire.position.y = 2;
this._scene.add(this._roomWire);
// Reflective floor
const floorGeo = new THREE.PlaneGeometry(12, 10);
this._floorMat = new THREE.MeshStandardMaterial({
color: 0x101810,
roughness: 1.0 - this.settings.reflect * 0.7,
metalness: this.settings.reflect * 0.5,
emissive: 0x020404,
emissiveIntensity: 0.08,
});
const floor = new THREE.Mesh(floorGeo, this._floorMat);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
this._scene.add(floor);
// Table under router
const tableGeo = new THREE.BoxGeometry(0.8, 0.6, 0.5);
const tableMat = new THREE.MeshStandardMaterial({ color: 0x6b5840, roughness: 0.55, emissive: 0x1a1408, emissiveIntensity: 0.25 });
const table = new THREE.Mesh(tableGeo, tableMat);
table.position.set(-4, 0.3, -3);
table.castShadow = true;
this._scene.add(table);
}
// ---- Router ----
_buildRouter() {
this._routerGroup = new THREE.Group();
this._routerGroup.position.set(-4, 0.92, -3);
const bodyGeo = new THREE.BoxGeometry(0.6, 0.12, 0.35);
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x505060, roughness: 0.2, metalness: 0.7, emissive: 0x101018, emissiveIntensity: 0.2 });
this._routerGroup.add(new THREE.Mesh(bodyGeo, bodyMat));
for (let i = -1; i <= 1; i++) {
const antGeo = new THREE.CylinderGeometry(0.015, 0.015, 0.35);
const antMat = new THREE.MeshStandardMaterial({ color: 0x606068, roughness: 0.3, metalness: 0.6, emissive: 0x101018, emissiveIntensity: 0.15 });
const ant = new THREE.Mesh(antGeo, antMat);
ant.position.set(i * 0.2, 0.24, 0);
ant.rotation.z = i * 0.15;
this._routerGroup.add(ant);
}
const ledGeo = new THREE.SphereGeometry(0.025);
this._routerLed = new THREE.Mesh(ledGeo, new THREE.MeshBasicMaterial({ color: C.greenGlow }));
this._routerLed.position.set(0.22, 0.07, 0.18);
this._routerGroup.add(this._routerLed);
this._routerLight = new THREE.PointLight(C.blueSignal, 1.2, 8);
this._routerLight.position.set(0, 0.3, 0);
this._routerGroup.add(this._routerLight);
this._scene.add(this._routerGroup);
}
// ---- WiFi Waves ----
_buildWifiWaves() {
this._wifiWaves = [];
for (let i = 0; i < 5; i++) {
const radius = 0.8 + i * 1.0;
const geo = new THREE.SphereGeometry(radius, 24, 16, 0, Math.PI * 2, 0, Math.PI * 0.6);
const mat = new THREE.MeshBasicMaterial({
color: C.blueSignal,
transparent: true, opacity: 0,
side: THREE.DoubleSide,
blending: THREE.AdditiveBlending,
depthWrite: false, wireframe: true,
});
const shell = new THREE.Mesh(geo, mat);
shell.position.copy(this._routerGroup.position);
shell.position.y += 0.5;
this._scene.add(shell);
this._wifiWaves.push({ mesh: shell, mat, phase: i * 0.7 });
}
}
// ========================================
// DOT MATRIX MIST
// ========================================
_buildDotMatrixMist() {
const COUNT = 800;
const positions = new Float32Array(COUNT * 3);
const alphas = new Float32Array(COUNT);
for (let i = 0; i < COUNT; i++) {
const angle = Math.random() * Math.PI * 2;
const r = Math.random() * 0.5;
positions[i * 3] = Math.cos(angle) * r;
positions[i * 3 + 1] = Math.random() * 1.8;
positions[i * 3 + 2] = Math.sin(angle) * r;
alphas[i] = 0;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('alpha', new THREE.BufferAttribute(alphas, 1));
const mat = new THREE.ShaderMaterial({
vertexShader: `
attribute float alpha;
varying float vAlpha;
void main() {
vAlpha = alpha;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = 3.0 * (200.0 / -mv.z);
gl_Position = projectionMatrix * mv;
}
`,
fragmentShader: `
uniform vec3 uColor;
varying float vAlpha;
void main() {
float d = length(gl_PointCoord - 0.5);
if (d > 0.5) discard;
float edge = smoothstep(0.5, 0.2, d);
gl_FragColor = vec4(uColor, edge * vAlpha);
}
`,
uniforms: { uColor: { value: new THREE.Color(this.settings.wireColor) } },
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
});
this._mistPoints = new THREE.Points(geo, mat);
this._scene.add(this._mistPoints);
this._mistCount = COUNT;
}
// ---- Particle Trail ----
_buildParticleTrail() {
const COUNT = 200;
const positions = new Float32Array(COUNT * 3);
const ages = new Float32Array(COUNT);
for (let i = 0; i < COUNT; i++) ages[i] = 1;
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('age', new THREE.BufferAttribute(ages, 1));
const mat = new THREE.ShaderMaterial({
vertexShader: `
attribute float age;
varying float vAge;
void main() {
vAge = age;
vec4 mv = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = max(1.0, (1.0 - age) * 5.0 * (150.0 / -mv.z));
gl_Position = projectionMatrix * mv;
}
`,
fragmentShader: `
uniform vec3 uColor;
varying float vAge;
void main() {
float d = length(gl_PointCoord - 0.5);
if (d > 0.5) discard;
float alpha = (1.0 - vAge) * 0.6 * smoothstep(0.5, 0.1, d);
gl_FragColor = vec4(uColor, alpha);
}
`,
uniforms: { uColor: { value: new THREE.Color(C.greenGlow) } },
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
});
this._trail = new THREE.Points(geo, mat);
this._scene.add(this._trail);
this._trailHead = 0;
this._trailCount = COUNT;
this._trailTimer = 0;
}
// ---- Signal Field ----
_buildSignalField() {
const gridSize = 20;
const count = gridSize * gridSize;
const positions = new Float32Array(count * 3);
this._fieldColors = new Float32Array(count * 3);
this._fieldSizes = new Float32Array(count);
for (let iz = 0; iz < gridSize; iz++) {
for (let ix = 0; ix < gridSize; ix++) {
const idx = iz * gridSize + ix;
positions[idx * 3] = (ix - gridSize / 2) * 0.6;
positions[idx * 3 + 1] = 0.02;
positions[idx * 3 + 2] = (iz - gridSize / 2) * 0.5;
this._fieldSizes[idx] = 8;
}
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.BufferAttribute(this._fieldColors, 3));
geo.setAttribute('size', new THREE.BufferAttribute(this._fieldSizes, 1));
this._fieldMat = new THREE.PointsMaterial({
size: 0.35, vertexColors: true, transparent: true,
opacity: this.settings.field, blending: THREE.AdditiveBlending,
depthWrite: false, sizeAttenuation: true,
});
this._fieldPoints = new THREE.Points(geo, this._fieldMat);
this._scene.add(this._fieldPoints);
}
// ---- Keyboard ----
_initKeyboard() {
window.addEventListener('keydown', (e) => {
if (this._hud.settingsOpen) return;
switch (e.key.toLowerCase()) {
case 'a':
this._autopilot = !this._autopilot;
this._controls.enabled = !this._autopilot;
break;
case 'd': this._demoData.cycleScenario(); break;
case 'f':
this._showFps = !this._showFps;
document.getElementById('fps-counter').style.display = this._showFps ? 'block' : 'none';
break;
case 's': this._hud.toggleSettings(); break;
case ' ':
e.preventDefault();
this._demoData.paused = !this._demoData.paused;
break;
}
});
}
// ---- Settings / HUD methods delegated to HudController ----
_applyPostSettings() {
const pp = this._postProcessing;
pp._bloomPass.strength = this.settings.bloom;
pp._bloomPass.radius = this.settings.bloomRadius;
pp._bloomPass.threshold = this.settings.bloomThresh;
pp._vignettePass.uniforms.uVignetteStrength.value = this.settings.vignette;
pp._vignettePass.uniforms.uGrainStrength.value = this.settings.grain;
pp._vignettePass.uniforms.uChromaticStrength.value = this.settings.chromatic;
}
_applyColors() {
const wc = new THREE.Color(this.settings.wireColor);
const jc = new THREE.Color(this.settings.jointColor);
this._figurePool.applyColors(wc, jc);
this._mistPoints.material.uniforms.uColor.value.copy(wc);
}
// ---- WebSocket live data ----
_autoDetectLive() {
// Probe sensing server health on same origin, then common ports
const host = window.location.hostname || 'localhost';
const candidates = [
window.location.origin, // same origin (e.g. :3000)
`http://${host}:8765`, // default WS port
`http://${host}:3000`, // default HTTP port
];
// Deduplicate
const unique = [...new Set(candidates)];
const tryNext = (i) => {
if (i >= unique.length) {
console.log('[Observatory] No sensing server detected, using demo mode');
return;
}
const base = unique[i];
fetch(`${base}/health`, { signal: AbortSignal.timeout(1500) })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
if (data && data.status === 'ok') {
const wsProto = base.startsWith('https') ? 'wss:' : 'ws:';
const urlObj = new URL(base);
const wsUrl = `${wsProto}//${urlObj.host}/ws/sensing`;
console.log('[Observatory] Sensing server detected at', base, '→', wsUrl);
this.settings.dataSource = 'ws';
this.settings.wsUrl = wsUrl;
this._connectWS(wsUrl);
} else {
tryNext(i + 1);
}
})
.catch(() => tryNext(i + 1));
};
tryNext(0);
}
_connectWS(url) {
this._disconnectWS();
try {
this._ws = new WebSocket(url);
this._ws.onopen = () => {
console.log('[Observatory] WebSocket connected');
this._hud.updateSourceBadge('ws', this._ws);
};
this._ws.onmessage = (evt) => { try { this._liveData = JSON.parse(evt.data); } catch {} };
this._ws.onclose = () => {
console.log('[Observatory] WebSocket closed, falling back to demo');
this._ws = null;
this.settings.dataSource = 'demo';
this._hud.updateSourceBadge('demo', null);
};
this._ws.onerror = () => {};
} catch {}
}
_disconnectWS() {
if (this._ws) { this._ws.close(); this._ws = null; }
this._liveData = null;
}
// ========================================
// ANIMATION LOOP
// ========================================
_animate() {
requestAnimationFrame(() => this._animate());
const dt = Math.min(this._clock.getDelta(), 0.1);
const elapsed = this._clock.getElapsedTime();
// Data source
if (this.settings.dataSource === 'ws' && this._liveData) {
this._currentData = this._liveData;
} else {
this._currentData = this._demoData.update(dt);
}
const data = this._currentData;
// Updates
this._nebula.update(dt, elapsed);
this._figurePool.update(data, elapsed);
this._scenarioProps.update(data, this._demoData.currentScenario);
this._updateDotMatrixMist(data, elapsed);
this._updateParticleTrail(data, dt, elapsed);
this._updateWifiWaves(elapsed);
this._updateSignalField(data);
this._hud.updateHUD(data, this._demoData);
this._hud.updateSparkline(data);
// Router LED
this._routerLed.material.opacity = 0.5 + 0.5 * Math.sin(elapsed * 8);
this._routerLight.intensity = 0.3 + 0.2 * Math.sin(elapsed * 3);
// Autopilot orbit
if (this._autopilot) {
this._autoAngle += dt * this.settings.orbitSpeed;
const r = 10;
this._camera.position.set(
Math.sin(this._autoAngle) * r,
4.5 + Math.sin(this._autoAngle * 0.5),
Math.cos(this._autoAngle) * r
);
this._controls.target.set(0, 1.2, 0);
this._controls.update();
}
this._controls.update();
this._postProcessing.update(elapsed);
this._postProcessing.render();
this._updateFPS(dt);
}
// ========================================
// MIST & TRAIL
// ========================================
_updateDotMatrixMist(data, elapsed) {
const persons = data?.persons || [];
const isPresent = data?.classification?.presence || false;
const pos = this._mistPoints.geometry.attributes.position;
const alpha = this._mistPoints.geometry.attributes.alpha;
if (!isPresent || persons.length === 0) {
for (let i = 0; i < this._mistCount; i++) {
alpha.array[i] = Math.max(0, alpha.array[i] - 0.02);
}
alpha.needsUpdate = true;
return;
}
// Follow primary person
const pp = persons[0].position || [0, 0, 0];
const px = pp[0] || 0, pz = pp[2] || 0;
const ms = persons[0].motion_score || 0;
const pose = persons[0].pose || 'standing';
const isLying = pose === 'lying' || pose === 'fallen';
const bodyH = isLying ? 0.4 : 1.7;
const bodyBaseY = isLying ? (pp[1] || 0) + 0.05 : 0.05;
const spread = ms > 50 ? 0.6 : 0.4;
for (let i = 0; i < this._mistCount; i++) {
const drift = Math.sin(elapsed * 0.5 + i * 0.1) * 0.003;
const angle = (i / this._mistCount) * Math.PI * 2 + elapsed * 0.1;
const layerT = (i % 20) / 20;
const layerY = bodyBaseY + layerT * bodyH;
let bodyWidth;
if (isLying) {
bodyWidth = 0.25;
} else {
bodyWidth = layerT > 0.75 ? 0.15 : (layerT > 0.45 ? 0.25 : 0.18);
}
const r = bodyWidth * (0.5 + 0.5 * Math.sin(i * 1.7 + elapsed * 0.3)) * spread;
const tx = px + Math.cos(angle + i * 0.3) * r + drift;
const tz = pz + Math.sin(angle + i * 0.5) * r * 0.6;
pos.array[i * 3] += (tx - pos.array[i * 3]) * 0.05;
pos.array[i * 3 + 1] += (layerY - pos.array[i * 3 + 1]) * 0.05;
pos.array[i * 3 + 2] += (tz - pos.array[i * 3 + 2]) * 0.05;
const targetAlpha = 0.15 + Math.sin(elapsed * 2 + i * 0.5) * 0.08;
alpha.array[i] += (targetAlpha - alpha.array[i]) * 0.08;
}
pos.needsUpdate = true;
alpha.needsUpdate = true;
}
_updateParticleTrail(data, dt, elapsed) {
if (this.settings.trail <= 0) return;
const persons = data?.persons || [];
const isPresent = data?.classification?.presence || false;
const pos = this._trail.geometry.attributes.position;
const ages = this._trail.geometry.attributes.age;
for (let i = 0; i < this._trailCount; i++) {
ages.array[i] = Math.min(1, ages.array[i] + dt * 0.8);
}
// Emit from all active persons
if (isPresent && persons.length > 0) {
this._trailTimer += dt;
const ms = persons[0].motion_score || 0;
const emitRate = ms > 50 ? 0.02 : 0.08;
if (this._trailTimer >= emitRate) {
this._trailTimer = 0;
for (const p of persons) {
const pp = p.position || [0, 0, 0];
const idx = this._trailHead;
pos.array[idx * 3] = (pp[0] || 0) + (Math.random() - 0.5) * 0.15;
pos.array[idx * 3 + 1] = Math.random() * 1.5 + 0.1;
pos.array[idx * 3 + 2] = (pp[2] || 0) + (Math.random() - 0.5) * 0.15;
ages.array[idx] = 0;
this._trailHead = (this._trailHead + 1) % this._trailCount;
}
}
}
pos.needsUpdate = true;
ages.needsUpdate = true;
}
// ---- WiFi Waves ----
_updateWifiWaves(elapsed) {
for (const w of this._wifiWaves) {
const t = (elapsed * 0.8 + w.phase) % 4.5;
const life = t / 4.5;
w.mat.opacity = Math.max(0, this.settings.waves * 0.25 * (1 - life));
const scale = 1 + life * 0.6;
w.mesh.scale.set(scale, scale, scale);
w.mesh.rotation.y = elapsed * 0.05;
}
}
// ---- Signal Field ----
_updateSignalField(data) {
const field = data?.signal_field?.values;
if (!field) return;
const count = Math.min(field.length, 400);
for (let i = 0; i < count; i++) {
const v = field[i] || 0;
let r, g, b;
if (v < 0.3) { r = 0; g = v * 1.5; b = v * 0.3; }
else if (v < 0.6) {
const t = (v - 0.3) / 0.3;
r = t * 0.3; g = 0.45 + t * 0.4; b = 0.09 - t * 0.05;
} else {
const t = (v - 0.6) / 0.4;
r = 0.3 + t * 0.7; g = 0.85 - t * 0.2; b = 0.04;
}
this._fieldColors[i * 3] = r;
this._fieldColors[i * 3 + 1] = g;
this._fieldColors[i * 3 + 2] = b;
this._fieldSizes[i] = 5 + v * 15;
}
this._fieldPoints.geometry.attributes.color.needsUpdate = true;
this._fieldPoints.geometry.attributes.size.needsUpdate = true;
}
// ---- FPS ----
_updateFPS(dt) {
this._fpsFrames++;
this._fpsTime += dt;
if (this._fpsTime >= 1) {
this._fpsValue = Math.round(this._fpsFrames / this._fpsTime);
this._fpsFrames = 0;
this._fpsTime = 0;
if (this._showFps) {
document.getElementById('fps-counter').textContent = `${this._fpsValue} FPS`;
}
this._adaptQuality();
}
}
_adaptQuality() {
let nl = this._qualityLevel;
if (this._fpsValue < 25 && nl > 0) nl--;
else if (this._fpsValue > 55 && nl < 2) nl++;
if (nl !== this._qualityLevel) {
this._qualityLevel = nl;
this._nebula.setQuality(nl);
this._postProcessing.setQuality(nl);
}
}
_onResize() {
const w = window.innerWidth, h = window.innerHeight;
this._camera.aspect = w / h;
this._camera.updateProjectionMatrix();
this._renderer.setSize(w, h);
this._postProcessing.resize(w, h);
}
}
new Observatory();

View File

@ -1,115 +0,0 @@
/**
* Room Atmosphere Background Warm dark gradient with subtle particles
* Matches RuView Foundation aesthetic: deep blue-black with warm undertones
*/
import * as THREE from 'three';
const BG_VERTEX = `
varying vec3 vWorldPos;
void main() {
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const BG_FRAGMENT = `
uniform float uTime;
uniform float uOctaves;
varying vec3 vWorldPos;
vec3 hash33(vec3 p) {
p = fract(p * vec3(443.8975, 397.2973, 491.1871));
p += dot(p, p.yxz + 19.19);
return fract(vec3(p.x * p.y, p.y * p.z, p.z * p.x));
}
float noise3d(vec3 p) {
vec3 i = floor(p);
vec3 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float n = mix(
mix(mix(dot(hash33(i), f), dot(hash33(i + vec3(1,0,0)), f - vec3(1,0,0)), f.x),
mix(dot(hash33(i + vec3(0,1,0)), f - vec3(0,1,0)), dot(hash33(i + vec3(1,1,0)), f - vec3(1,1,0)), f.x), f.y),
mix(mix(dot(hash33(i + vec3(0,0,1)), f - vec3(0,0,1)), dot(hash33(i + vec3(1,0,1)), f - vec3(1,0,1)), f.x),
mix(dot(hash33(i + vec3(0,1,1)), f - vec3(0,1,1)), dot(hash33(i + vec3(1,1,1)), f - vec3(1,1,1)), f.x), f.y),
f.z);
return n * 0.5 + 0.5;
}
float fbm(vec3 p, float octaves) {
float v = 0.0, a = 0.5;
for (float i = 0.0; i < 5.0; i++) {
if (i >= octaves) break;
v += a * noise3d(p);
p *= 2.0;
a *= 0.5;
}
return v;
}
void main() {
vec3 dir = normalize(vWorldPos);
// Warm dark atmosphere with subtle color variation
float n1 = fbm(dir * 2.5 + uTime * 0.008, uOctaves);
float n2 = fbm(dir * 4.0 - uTime * 0.005, max(1.0, uOctaves - 1.0));
// Foundation palette: deep blue-black with warm undertones
vec3 deepBlack = vec3(0.03, 0.04, 0.06);
vec3 warmNavy = vec3(0.04, 0.05, 0.10);
vec3 greenTint = vec3(0.01, 0.06, 0.04);
vec3 bg = mix(deepBlack, warmNavy, n1 * 0.5);
bg = mix(bg, greenTint, n2 * 0.15);
// Subtle top-down gradient (lighter ceiling)
float upFactor = max(0.0, dir.y) * 0.08;
bg += vec3(0.02, 0.03, 0.05) * upFactor;
// Very subtle dim stars (distant)
vec3 c = floor(dir * 200.0);
vec3 h = hash33(c);
float star = step(0.998, h.x) * h.y * 0.15;
star *= 0.7 + 0.3 * sin(uTime * 1.5 + h.z * 80.0);
bg += vec3(0.6, 0.7, 0.8) * star;
gl_FragColor = vec4(bg, 1.0);
}
`;
export class NebulaBackground {
constructor(scene) {
this._octaves = 4;
this.uniforms = {
uTime: { value: 0 },
uOctaves: { value: this._octaves },
};
const geo = new THREE.SphereGeometry(150, 32, 32);
const mat = new THREE.ShaderMaterial({
vertexShader: BG_VERTEX,
fragmentShader: BG_FRAGMENT,
uniforms: this.uniforms,
side: THREE.BackSide,
depthWrite: false,
});
this.mesh = new THREE.Mesh(geo, mat);
scene.add(this.mesh);
}
update(dt, elapsed) {
this.uniforms.uTime.value = elapsed;
}
setQuality(level) {
this._octaves = [2, 3, 4][level] || 4;
this.uniforms.uOctaves.value = this._octaves;
}
dispose() {
this.mesh.geometry.dispose();
this.mesh.material.dispose();
}
}

View File

@ -1,170 +0,0 @@
/**
* Module D "The Phase Constellation"
* I/Q star map with constellation lines and rotating temporal view
*/
import * as THREE from 'three';
const NUM_SUBCARRIERS = 64;
export class PhaseConstellation {
constructor(scene, panelGroup) {
this.group = new THREE.Group();
if (panelGroup) panelGroup.add(this.group);
else scene.add(this.group);
// Star points (current frame)
const starGeo = new THREE.BufferGeometry();
this._positions = new Float32Array(NUM_SUBCARRIERS * 3);
this._colors = new Float32Array(NUM_SUBCARRIERS * 3);
this._sizes = new Float32Array(NUM_SUBCARRIERS);
starGeo.setAttribute('position', new THREE.BufferAttribute(this._positions, 3));
starGeo.setAttribute('color', new THREE.BufferAttribute(this._colors, 3));
starGeo.setAttribute('size', new THREE.BufferAttribute(this._sizes, 1));
const starMat = new THREE.PointsMaterial({
size: 0.12,
vertexColors: true,
transparent: true,
opacity: 0.9,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true,
});
this._stars = new THREE.Points(starGeo, starMat);
this.group.add(this._stars);
// Ghost layer (previous frame)
const ghostGeo = new THREE.BufferGeometry();
this._ghostPos = new Float32Array(NUM_SUBCARRIERS * 3);
ghostGeo.setAttribute('position', new THREE.BufferAttribute(this._ghostPos, 3));
const ghostMat = new THREE.PointsMaterial({
color: 0x00d4ff,
size: 0.06,
transparent: true,
opacity: 0.2,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true,
});
this._ghosts = new THREE.Points(ghostGeo, ghostMat);
this.group.add(this._ghosts);
// Constellation lines (connecting adjacent subcarriers)
const lineGeo = new THREE.BufferGeometry();
this._linePos = new Float32Array(NUM_SUBCARRIERS * 2 * 3); // pairs
lineGeo.setAttribute('position', new THREE.BufferAttribute(this._linePos, 3));
const lineMat = new THREE.LineBasicMaterial({
color: 0x00d4ff,
transparent: true,
opacity: 0.15,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
this._lines = new THREE.LineSegments(lineGeo, lineMat);
this.group.add(this._lines);
// Axes
this._addAxes();
this._prevIQ = null;
}
_addAxes() {
const axesMat = new THREE.LineBasicMaterial({
color: 0x00d4ff,
transparent: true,
opacity: 0.1,
});
// I axis
const iGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(-2.5, 0, 0),
new THREE.Vector3(2.5, 0, 0),
]);
this.group.add(new THREE.Line(iGeo, axesMat));
// Q axis
const qGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, -2.5, 0),
new THREE.Vector3(0, 2.5, 0),
]);
this.group.add(new THREE.Line(qGeo, axesMat));
}
update(dt, elapsed, data) {
const iq = data?._observatory?.subcarrier_iq;
const variance = data?._observatory?.per_subcarrier_variance;
const amplitude = data?.nodes?.[0]?.amplitude;
// Slow Y rotation for temporal evolution
this.group.rotation.y = elapsed * 0.05;
if (!iq || iq.length < NUM_SUBCARRIERS) return;
// Copy current to ghost
this._ghostPos.set(this._positions);
this._ghosts.geometry.attributes.position.needsUpdate = true;
// Update current positions from I/Q
for (let s = 0; s < NUM_SUBCARRIERS; s++) {
const i3 = s * 3;
const iVal = (iq[s]?.i || 0) * 4; // scale for visibility
const qVal = (iq[s]?.q || 0) * 4;
this._positions[i3] = iVal;
this._positions[i3 + 1] = qVal;
this._positions[i3 + 2] = 0;
// Size from amplitude
const amp = amplitude ? (amplitude[s % amplitude.length] || 0.1) : 0.1;
this._sizes[s] = 0.06 + amp * 0.15;
// Color from variance: blue(low) -> amber(high)
const v = variance ? Math.min(1, (variance[s] || 0) * 2) : 0;
this._colors[i3] = v * 1.0; // R
this._colors[i3 + 1] = 0.5 + v * 0.3; // G
this._colors[i3 + 2] = 1.0 - v * 0.7; // B
}
this._stars.geometry.attributes.position.needsUpdate = true;
this._stars.geometry.attributes.color.needsUpdate = true;
this._stars.geometry.attributes.size.needsUpdate = true;
// Update constellation lines
for (let s = 0; s < NUM_SUBCARRIERS - 1; s++) {
const li = s * 6;
const i3a = s * 3;
const i3b = (s + 1) * 3;
this._linePos[li] = this._positions[i3a];
this._linePos[li + 1] = this._positions[i3a + 1];
this._linePos[li + 2] = this._positions[i3a + 2];
this._linePos[li + 3] = this._positions[i3b];
this._linePos[li + 4] = this._positions[i3b + 1];
this._linePos[li + 5] = this._positions[i3b + 2];
}
// Last pair: wrap around
const lastLi = (NUM_SUBCARRIERS - 1) * 6;
const lastI3 = (NUM_SUBCARRIERS - 1) * 3;
this._linePos[lastLi] = this._positions[lastI3];
this._linePos[lastLi + 1] = this._positions[lastI3 + 1];
this._linePos[lastLi + 2] = this._positions[lastI3 + 2];
this._linePos[lastLi + 3] = this._positions[0];
this._linePos[lastLi + 4] = this._positions[1];
this._linePos[lastLi + 5] = this._positions[2];
this._lines.geometry.attributes.position.needsUpdate = true;
}
dispose() {
this._stars.geometry.dispose();
this._stars.material.dispose();
this._ghosts.geometry.dispose();
this._ghosts.material.dispose();
this._lines.geometry.dispose();
this._lines.material.dispose();
}
}

View File

@ -1,125 +0,0 @@
/**
* Post-Processing Subtle bloom for green glow wireframe,
* warm vignette, minimal grain. Foundation-style.
*/
import * as THREE from 'three';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
const VignetteShader = {
uniforms: {
tDiffuse: { value: null },
uTime: { value: 0 },
uVignetteStrength: { value: 0.5 },
uChromaticStrength: { value: 0.0015 },
uGrainStrength: { value: 0.03 },
uWarmth: { value: 0.08 },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D tDiffuse;
uniform float uTime;
uniform float uVignetteStrength;
uniform float uChromaticStrength;
uniform float uGrainStrength;
uniform float uWarmth;
varying vec2 vUv;
float rand(vec2 co) {
return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
}
void main() {
vec2 uv = vUv;
vec2 center = uv - 0.5;
float dist = length(center);
// Subtle chromatic aberration at edges only
vec2 offset = center * dist * uChromaticStrength;
float r = texture2D(tDiffuse, uv + offset).r;
float g = texture2D(tDiffuse, uv).g;
float b = texture2D(tDiffuse, uv - offset * 0.5).b;
vec3 color = vec3(r, g, b);
// Warm vignette
float vignette = 1.0 - dist * dist * uVignetteStrength * 1.8;
color *= vignette;
// Very subtle warm shift in shadows
float luma = dot(color, vec3(0.299, 0.587, 0.114));
color.r += (1.0 - luma) * uWarmth * 0.5;
color.g += (1.0 - luma) * uWarmth * 0.2;
// Minimal grain
float grain = (rand(uv * uTime * 0.01) - 0.5) * uGrainStrength;
color += grain;
gl_FragColor = vec4(color, 1.0);
}
`,
};
export class PostProcessing {
constructor(renderer, scene, camera) {
const size = renderer.getSize(new THREE.Vector2());
this.composer = new EffectComposer(renderer);
this.composer.addPass(new RenderPass(scene, camera));
// Bloom — tuned for green wireframe glow
this._bloomPass = new UnrealBloomPass(
new THREE.Vector2(size.x, size.y),
0.08, // strength — subtle glow, overridden by settings
0.2, // radius
0.6 // threshold
);
this.composer.addPass(this._bloomPass);
// Vignette + warmth
this._vignettePass = new ShaderPass(VignetteShader);
this.composer.addPass(this._vignettePass);
this._bloomEnabled = true;
}
update(elapsed) {
this._vignettePass.uniforms.uTime.value = elapsed;
}
render() {
this.composer.render();
}
resize(width, height) {
this.composer.setSize(width, height);
this._bloomPass.resolution.set(width, height);
}
setQuality(level) {
if (level === 0) {
this._bloomPass.strength = 0;
this._vignettePass.uniforms.uChromaticStrength.value = 0;
this._vignettePass.uniforms.uGrainStrength.value = 0;
} else if (level === 1) {
this._bloomPass.strength = 0.6;
this._vignettePass.uniforms.uChromaticStrength.value = 0.001;
this._vignettePass.uniforms.uGrainStrength.value = 0.02;
} else {
this._bloomPass.strength = 1.0;
this._vignettePass.uniforms.uChromaticStrength.value = 0.0015;
this._vignettePass.uniforms.uGrainStrength.value = 0.03;
}
}
dispose() {
this.composer.dispose();
}
}

View File

@ -1,178 +0,0 @@
/**
* Module C "Presence Cartography"
* InstancedMesh 20x4x20 voxel heatmap with person lights
*/
import * as THREE from 'three';
const GRID_X = 20;
const GRID_Y = 4;
const GRID_Z = 20;
const TOTAL_VOXELS = GRID_X * GRID_Y * GRID_Z;
const VOXEL_SIZE = 0.22;
export class PresenceCartography {
constructor(scene, panelGroup) {
this.group = new THREE.Group();
if (panelGroup) panelGroup.add(this.group);
else scene.add(this.group);
// Instanced cubes
const cubeGeo = new THREE.BoxGeometry(VOXEL_SIZE, VOXEL_SIZE, VOXEL_SIZE);
const cubeMat = new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 1,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
this._mesh = new THREE.InstancedMesh(cubeGeo, cubeMat, TOTAL_VOXELS);
this._mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
// Color attribute
this._colors = new Float32Array(TOTAL_VOXELS * 3);
this._mesh.instanceColor = new THREE.InstancedBufferAttribute(this._colors, 3);
// Initialize positions
const dummy = new THREE.Object3D();
const halfX = (GRID_X * VOXEL_SIZE * 1.1) / 2;
const halfZ = (GRID_Z * VOXEL_SIZE * 1.1) / 2;
for (let y = 0; y < GRID_Y; y++) {
for (let z = 0; z < GRID_Z; z++) {
for (let x = 0; x < GRID_X; x++) {
const idx = y * GRID_Z * GRID_X + z * GRID_X + x;
dummy.position.set(
x * VOXEL_SIZE * 1.1 - halfX,
y * VOXEL_SIZE * 1.1,
z * VOXEL_SIZE * 1.1 - halfZ
);
dummy.scale.set(0.01, 0.01, 0.01); // start invisible
dummy.updateMatrix();
this._mesh.setMatrixAt(idx, dummy.matrix);
this._colors[idx * 3] = 0;
this._colors[idx * 3 + 1] = 0.2;
this._colors[idx * 3 + 2] = 0.4;
}
}
}
this._mesh.instanceMatrix.needsUpdate = true;
this._mesh.instanceColor.needsUpdate = true;
this.group.add(this._mesh);
// Room wireframe
const roomW = GRID_X * VOXEL_SIZE * 1.1;
const roomH = GRID_Y * VOXEL_SIZE * 1.1;
const roomD = GRID_Z * VOXEL_SIZE * 1.1;
const boxGeo = new THREE.BoxGeometry(roomW, roomH, roomD);
const edges = new THREE.EdgesGeometry(boxGeo);
const lineMat = new THREE.LineBasicMaterial({
color: 0x00d4ff,
transparent: true,
opacity: 0.15,
});
const wireframe = new THREE.LineSegments(edges, lineMat);
wireframe.position.y = roomH / 2;
this.group.add(wireframe);
// Person lights (up to 4)
this._personLights = [];
for (let i = 0; i < 4; i++) {
const light = new THREE.PointLight(0xff8800, 0, 3);
this.group.add(light);
this._personLights.push(light);
}
this._dummy = new THREE.Object3D();
this._halfX = halfX;
this._halfZ = halfZ;
}
update(dt, elapsed, data) {
const field = data?.signal_field?.values;
const persons = data?.persons || [];
const dummy = this._dummy;
if (field && field.length >= GRID_X * GRID_Z) {
for (let y = 0; y < GRID_Y; y++) {
for (let z = 0; z < GRID_Z; z++) {
for (let x = 0; x < GRID_X; x++) {
const idx = y * GRID_Z * GRID_X + z * GRID_X + x;
const fieldIdx = z * GRID_X + x;
const val = field[fieldIdx] || 0;
// Extrude vertically: layer 0 = full val, higher layers diminish
const layerFactor = Math.max(0, 1 - y / GRID_Y);
const v = val * layerFactor;
// Scale voxel by value
const s = v > 0.05 ? 0.3 + v * 0.7 : 0.01;
dummy.position.set(
x * VOXEL_SIZE * 1.1 - this._halfX,
y * VOXEL_SIZE * 1.1,
z * VOXEL_SIZE * 1.1 - this._halfZ
);
dummy.scale.set(s, s, s);
dummy.updateMatrix();
this._mesh.setMatrixAt(idx, dummy.matrix);
// Color: blue(low) -> cyan(mid) -> amber(high)
let r, g, b;
if (v < 0.3) {
const t = v / 0.3;
r = 0.02;
g = 0.06 + t * 0.6;
b = 0.2 + t * 0.6;
} else if (v < 0.6) {
const t = (v - 0.3) / 0.3;
r = t * 0.8;
g = 0.66 + t * 0.2;
b = 0.8 - t * 0.5;
} else {
const t = (v - 0.6) / 0.4;
r = 0.8 + t * 0.2;
g = 0.86 - t * 0.5;
b = 0.3 - t * 0.3;
}
this._colors[idx * 3] = r;
this._colors[idx * 3 + 1] = g;
this._colors[idx * 3 + 2] = b;
}
}
}
this._mesh.instanceMatrix.needsUpdate = true;
this._mesh.instanceColor.needsUpdate = true;
}
// Person lights
for (let i = 0; i < this._personLights.length; i++) {
const light = this._personLights[i];
if (i < persons.length) {
const p = persons[i].position || [0, 0, 0];
light.position.set(p[0] * 2, 1.5, p[2] * 2);
light.intensity = 1.5 + Math.sin(elapsed * 3 + i) * 0.5;
light.color.setHex(0xff8800);
} else {
light.intensity = 0;
}
}
}
/** Reduce voxel count for performance */
setQuality(level) {
// For now just toggle visibility of upper layers
// level 0 = show only ground, 2 = show all
this._mesh.count = level === 0
? GRID_X * GRID_Z
: level === 1
? GRID_X * GRID_Z * 2
: TOTAL_VOXELS;
}
dispose() {
this._mesh.geometry.dispose();
this._mesh.material.dispose();
}
}

View File

@ -1,163 +0,0 @@
/**
* Module A "The Subcarrier Manifold"
* 3D scrolling surface: 64 subcarriers x 60 time slots
*/
import * as THREE from 'three';
const MANIFOLD_VERTEX = `
attribute float aHeight;
attribute float aAge; // 0 = newest, 1 = oldest
varying float vHeight;
varying float vAge;
void main() {
vec3 pos = position;
pos.y += aHeight * 2.0;
vHeight = aHeight;
vAge = aAge;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`;
const MANIFOLD_FRAGMENT = `
uniform float uTime;
varying float vHeight;
varying float vAge;
void main() {
// Color map: low=deep blue, mid=cyan, high=amber
vec3 lo = vec3(0.02, 0.06, 0.2);
vec3 mid = vec3(0.0, 0.83, 1.0);
vec3 hi = vec3(1.0, 0.53, 0.0);
float h = clamp(vHeight, 0.0, 1.0);
vec3 col = h < 0.5
? mix(lo, mid, h * 2.0)
: mix(mid, hi, (h - 0.5) * 2.0);
// Fade older rows
float alpha = 0.3 + 0.7 * (1.0 - vAge);
gl_FragColor = vec4(col, alpha);
}
`;
const SUBS = 64;
const TIME_SLOTS = 60;
export class SubcarrierManifold {
constructor(scene, panelGroup) {
this.group = new THREE.Group();
if (panelGroup) panelGroup.add(this.group);
else scene.add(this.group);
this._history = []; // ring buffer of Float32Array[64]
for (let i = 0; i < TIME_SLOTS; i++) {
this._history.push(new Float32Array(SUBS));
}
this._head = 0;
// Build surface geometry
const geo = new THREE.PlaneGeometry(8, 5, SUBS - 1, TIME_SLOTS - 1);
const vertCount = SUBS * TIME_SLOTS;
this._heights = new Float32Array(vertCount);
this._ages = new Float32Array(vertCount);
for (let t = 0; t < TIME_SLOTS; t++) {
for (let s = 0; s < SUBS; s++) {
this._ages[t * SUBS + s] = t / TIME_SLOTS;
}
}
geo.setAttribute('aHeight', new THREE.BufferAttribute(this._heights, 1));
geo.setAttribute('aAge', new THREE.BufferAttribute(this._ages, 1));
// Solid surface
const mat = new THREE.ShaderMaterial({
vertexShader: MANIFOLD_VERTEX,
fragmentShader: MANIFOLD_FRAGMENT,
uniforms: { uTime: { value: 0 } },
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
this._mesh = new THREE.Mesh(geo, mat);
this._mesh.rotation.x = -Math.PI * 0.35;
this.group.add(this._mesh);
// Wireframe overlay
const wireGeo = geo.clone();
wireGeo.setAttribute('aHeight', new THREE.BufferAttribute(this._heights, 1));
wireGeo.setAttribute('aAge', new THREE.BufferAttribute(this._ages, 1));
const wireMat = new THREE.ShaderMaterial({
vertexShader: MANIFOLD_VERTEX,
fragmentShader: `
varying float vHeight;
varying float vAge;
void main() {
float alpha = 0.15 * (1.0 - vAge);
gl_FragColor = vec4(0.0, 0.83, 1.0, alpha);
}
`,
uniforms: { uTime: { value: 0 } },
transparent: true,
wireframe: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
this._wire = new THREE.Mesh(wireGeo, wireMat);
this._wire.rotation.x = -Math.PI * 0.35;
this.group.add(this._wire);
this._frameAccum = 0;
this._pushInterval = 1 / 15; // push ~15 rows/sec
}
update(dt, elapsed, data) {
this._mesh.material.uniforms.uTime.value = elapsed;
// Push new amplitude data at regular intervals
this._frameAccum += dt;
if (this._frameAccum >= this._pushInterval && data) {
this._frameAccum = 0;
const amp = data.nodes?.[0]?.amplitude;
const row = new Float32Array(SUBS);
if (amp && amp.length > 0) {
for (let i = 0; i < SUBS; i++) {
row[i] = amp[i % amp.length] || 0;
}
}
this._history[this._head] = row;
this._head = (this._head + 1) % TIME_SLOTS;
this._rebuildHeights();
}
}
_rebuildHeights() {
for (let t = 0; t < TIME_SLOTS; t++) {
const histIdx = (this._head + t) % TIME_SLOTS;
const row = this._history[histIdx];
for (let s = 0; s < SUBS; s++) {
const idx = t * SUBS + s;
this._heights[idx] = row[s];
this._ages[idx] = t / TIME_SLOTS;
}
}
const geo = this._mesh.geometry;
geo.attributes.aHeight.needsUpdate = true;
geo.attributes.aAge.needsUpdate = true;
const wGeo = this._wire.geometry;
wGeo.attributes.aHeight.needsUpdate = true;
wGeo.attributes.aAge.needsUpdate = true;
}
dispose() {
this._mesh.geometry.dispose();
this._mesh.material.dispose();
this._wire.geometry.dispose();
this._wire.material.dispose();
}
}

View File

@ -1,187 +0,0 @@
/**
* 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();
}
}