feat: adaptive CSI classifier with signal smoothing pipeline (ADR-048) (#144)

Add environment-tuned activity classification that learns from labeled
ESP32 CSI recordings, replacing brittle static thresholds.

- Adaptive classifier: 15-feature logistic regression trained from JSONL
  recordings (variance, motion band, subcarrier stats: skew, kurtosis,
  entropy, IQR). Trains in <1s, persists as JSON, auto-loads on restart.
- Three-stage signal smoothing: adaptive baseline subtraction (α=0.003),
  EMA + trimmed-mean median filter (21-frame window), hysteresis debounce
  (4 frames). Motion classification now stable across seconds, not frames.
- Vital signs stabilization: outlier rejection (±8 BPM HR, ±2 BPM BR),
  trimmed mean, dead-band (±2 BPM HR), EMA α=0.02. HR holds steady for
  10+ seconds instead of jumping 50 BPM every frame.
- Observatory auto-detect: always probes /health on startup, connects
  WebSocket to live ESP32 data automatically.
- New API endpoints: POST /api/v1/adaptive/train, GET /adaptive/status,
  POST /adaptive/unload for runtime model management.
- Updated user guide with Observatory, adaptive classifier tutorial,
  signal smoothing docs, and new troubleshooting entries.
This commit is contained in:
rUv
2026-03-05 10:15:18 -05:00
committed by GitHub
parent f771cf8461
commit 5fa61ba7ea
6 changed files with 2435 additions and 49 deletions
+567
View File
@@ -0,0 +1,567 @@
/**
* 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.2, bloomRadius: 0.25, bloomThresh: 0.5,
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 = '4';
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; });
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;
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('');
}
}
+715
View File
@@ -0,0 +1,715 @@
/**
* 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(0x446688, this.settings.ambient * 3.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();