diff --git a/observatory/js/hud-controller.js b/observatory/js/hud-controller.js index ddc9fdf2..0fc0de1c 100644 --- a/observatory/js/hud-controller.js +++ b/observatory/js/hud-controller.js @@ -19,7 +19,7 @@ export const SCENARIO_NAMES = [ ]; export const DEFAULTS = { - bloom: 0.2, bloomRadius: 0.25, bloomThresh: 0.5, + 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, @@ -28,7 +28,7 @@ export const DEFAULTS = { scenario: 'auto', cycle: 30, dataSource: 'demo', wsUrl: '', }; -export const SETTINGS_VERSION = '4'; +export const SETTINGS_VERSION = '5'; export const PRESETS = { foundation: {}, diff --git a/observatory/js/post-processing.js b/observatory/js/post-processing.js index 4ad33116..02186e38 100644 --- a/observatory/js/post-processing.js +++ b/observatory/js/post-processing.js @@ -77,9 +77,9 @@ export class PostProcessing { // Bloom — tuned for green wireframe glow this._bloomPass = new UnrealBloomPass( new THREE.Vector2(size.x, size.y), - 1.0, // strength (less aggressive than before) - 0.5, // radius - 0.25 // threshold + 0.08, // strength — subtle glow, overridden by settings + 0.2, // radius + 0.6 // threshold ); this.composer.addPass(this._bloomPass); diff --git a/ui/observatory/js/hud-controller.js b/ui/observatory/js/hud-controller.js new file mode 100644 index 00000000..0fc0de1c --- /dev/null +++ b/ui/observatory/js/hud-controller.js @@ -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.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 = '5'; + +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 += ``; + } + 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 `${m}`; + }).join(''); + } +} diff --git a/ui/observatory/js/post-processing.js b/ui/observatory/js/post-processing.js new file mode 100644 index 00000000..02186e38 --- /dev/null +++ b/ui/observatory/js/post-processing.js @@ -0,0 +1,125 @@ +/** + * 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(); + } +}