diff --git a/observatory/css/css/observatory.css b/observatory/css/css/observatory.css deleted file mode 100644 index e289d65f..00000000 --- a/observatory/css/css/observatory.css +++ /dev/null @@ -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; } -} diff --git a/observatory/js/js/convergence-engine.js b/observatory/js/js/convergence-engine.js deleted file mode 100644 index f9e45f84..00000000 --- a/observatory/js/js/convergence-engine.js +++ /dev/null @@ -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(); - } - } -} diff --git a/observatory/js/js/demo-data.js b/observatory/js/js/demo-data.js deleted file mode 100644 index 016fb059..00000000 --- a/observatory/js/js/demo-data.js +++ /dev/null @@ -1,1794 +0,0 @@ -/** - * Demo Data Generator — RuView Observatory - * - * Generates synthetic CSI data matching the SensingUpdate contract. - * 12 scenarios covering all edge module categories. - * Each person includes pose, facing, and scenario-specific motion data. - * Auto-cycles with cosine crossfade transitions. - * - * V2: Enhanced with temporally-correlated noise, spatially-coherent fields, - * physiologically accurate vital signs, and realistic behavioral patterns. - */ - -const SCENARIOS = [ - 'empty_room', - 'single_breathing', - 'two_walking', - 'fall_event', - 'sleep_monitoring', - 'intrusion_detect', - 'gesture_control', - 'crowd_occupancy', - 'search_rescue', - 'elderly_care', - 'fitness_tracking', - 'security_patrol', -]; - -const CROSSFADE_DURATION = 2; // seconds - -// --------------------------------------------------------------------------- -// Noise & utility functions (module-private) -// --------------------------------------------------------------------------- - -/** Seeded PRNG for deterministic per-scenario noise. */ -function _mulberry32(seed) { - return function () { - let t = (seed += 0x6d2b79f5); - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -/** - * Temporally-correlated noise (1st-order IIR low-pass filtered white noise). - * Returns a function noise(t) that produces smooth, non-teleporting values - * in approximately [-amplitude, +amplitude]. - * `smoothing` controls correlation: higher = smoother (0.9-0.99 typical). - */ -function _makeCorrelatedNoise(seed, smoothing = 0.95, amplitude = 1) { - const rng = _mulberry32(seed); - let state = 0; - let lastT = -1; - return function (t) { - // Step the filter forward for each new time tick - const steps = Math.max(1, Math.round((t - lastT) * 60)); // ~60 Hz internal - for (let i = 0; i < Math.min(steps, 120); i++) { - state = smoothing * state + (1 - smoothing) * (rng() * 2 - 1); - } - lastT = t; - return state * amplitude; - }; -} - -/** - * Perlin-like 1D noise via sine harmonics. - * Deterministic, smooth, and cheap. - */ -function _harmonicNoise(t, seed, octaves = 3) { - let v = 0, amp = 1, freq = 1; - for (let i = 0; i < octaves; i++) { - v += amp * Math.sin(t * freq + seed * (i + 1) * 1.618); - amp *= 0.5; - freq *= 2.17; - } - return v; -} - -/** Smooth step (hermite interpolation) */ -function _smoothstep(edge0, edge1, x) { - const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0))); - return t * t * (3 - 2 * t); -} - -/** Clamp */ -function _clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); } - -/** Lerp */ -function _lerp(a, b, t) { return a + (b - a) * t; } - -/** Gaussian blob value at distance d with given sigma */ -function _gaussian(d, sigma) { - return Math.exp(-(d * d) / (2 * sigma * sigma)); -} - -// --------------------------------------------------------------------------- -// Noise bank — pre-allocated correlated noise channels per scenario -// Each scenario gets its own set of noise functions so they don't interfere. -// --------------------------------------------------------------------------- -const _noiseBanks = {}; -function _getNoiseBank(scenario) { - if (!_noiseBanks[scenario]) { - const idx = SCENARIOS.indexOf(scenario); - const base = (idx + 1) * 1000; - _noiseBanks[scenario] = { - rssi: _makeCorrelatedNoise(base + 1, 0.97, 1.5), - breath: _makeCorrelatedNoise(base + 2, 0.92, 0.3), - hr: _makeCorrelatedNoise(base + 3, 0.94, 1.0), - motion: _makeCorrelatedNoise(base + 4, 0.90, 0.5), - field: _makeCorrelatedNoise(base + 5, 0.96, 0.2), - pos1: _makeCorrelatedNoise(base + 6, 0.98, 0.15), - pos2: _makeCorrelatedNoise(base + 7, 0.98, 0.15), - env: _makeCorrelatedNoise(base + 8, 0.99, 1.0), - spike: _makeCorrelatedNoise(base + 9, 0.80, 1.0), - }; - } - return _noiseBanks[scenario]; -} - - -export class DemoDataGenerator { - constructor() { - this._scenarioIndex = 0; - this._elapsed = 0; - this._paused = false; - this._prevFrame = null; - this._currFrame = null; - this._cycleDuration = 30; - this._autoMode = true; - } - - get currentScenario() { - return SCENARIOS[this._scenarioIndex]; - } - - get paused() { return this._paused; } - set paused(v) { this._paused = v; } - - cycleScenario() { - this._scenarioIndex = (this._scenarioIndex + 1) % SCENARIOS.length; - this._elapsed = 0; - } - - setScenario(name) { - const idx = SCENARIOS.indexOf(name); - if (idx >= 0) { - this._scenarioIndex = idx; - this._autoMode = false; - this._elapsed = 0; - } else if (name === 'auto') { - this._autoMode = true; - } - } - - setCycleDuration(seconds) { - this._cycleDuration = Math.max(5, seconds); - } - - /** Call each frame; returns blended SensingUpdate object */ - update(dt) { - if (this._paused) { - return this._currFrame || this._generate(this._scenarioIndex, this._elapsed); - } - - this._elapsed += dt; - - // Auto-cycle - if (this._autoMode && this._elapsed >= this._cycleDuration) { - this._elapsed -= this._cycleDuration; - this._scenarioIndex = (this._scenarioIndex + 1) % SCENARIOS.length; - } - - const t = this._elapsed; - const frame = this._generate(this._scenarioIndex, t); - - // Crossfade near transition boundaries - if (this._autoMode && t < CROSSFADE_DURATION) { - const prevIdx = (this._scenarioIndex - 1 + SCENARIOS.length) % SCENARIOS.length; - const prevFrame = this._generate(prevIdx, this._cycleDuration - CROSSFADE_DURATION + t); - const alpha = 0.5 + 0.5 * Math.cos(Math.PI * (1 - t / CROSSFADE_DURATION)); - this._currFrame = this._blend(prevFrame, frame, alpha); - } else { - this._currFrame = frame; - } - - return this._currFrame; - } - - // ---- Scenario generators ---- - - _generate(scenarioIdx, t) { - const name = SCENARIOS[scenarioIdx]; - switch (name) { - case 'empty_room': return this._emptyRoom(t); - case 'single_breathing': return this._singleBreathing(t); - case 'two_walking': return this._twoWalking(t); - case 'fall_event': return this._fallEvent(t); - case 'sleep_monitoring': return this._sleepMonitoring(t); - case 'intrusion_detect': return this._intrusionDetect(t); - case 'gesture_control': return this._gestureControl(t); - case 'crowd_occupancy': return this._crowdOccupancy(t); - case 'search_rescue': return this._searchRescue(t); - case 'elderly_care': return this._elderlyCare(t); - case 'fitness_tracking': return this._fitnessTracking(t); - case 'security_patrol': return this._securityPatrol(t); - default: return this._emptyRoom(t); - } - } - - // ---- Base template ---- - - _baseFrame(overrides) { - return { - type: 'sensing_update', - timestamp: Date.now() / 1000, - source: 'demo', - scenario: SCENARIOS[this._scenarioIndex], - nodes: [{ node_id: 1, rssi_dbm: -45, position: [2, 0, 1.5], amplitude: new Float32Array(64), subcarrier_count: 64 }], - features: { mean_rssi: -45, variance: 0.3, std: 0.55, motion_band_power: 0.02, breathing_band_power: 0.01, dominant_freq_hz: 0.05, spectral_power: 0.03 }, - classification: { motion_level: 'absent', presence: false, confidence: 0.92 }, - signal_field: { grid_size: [20, 1, 20], values: this._flatField(0.05) }, - vital_signs: { breathing_rate_bpm: 0, heart_rate_bpm: 0, breathing_confidence: 0, heart_rate_confidence: 0 }, - persons: [], - estimated_persons: 0, - edge_modules: {}, - _observatory: { subcarrier_iq: [], per_subcarrier_variance: new Float32Array(64).fill(0.02) }, - ...overrides, - }; - } - - // ======================================================================== - // 1. Empty Room — environmental noise, interference spikes, day/night drift - // ======================================================================== - - _emptyRoom(t) { - const n = _getNoiseBank('empty_room'); - - // Day/night RSSI drift: slow sinusoidal cycle over the scenario duration - const dayNightDrift = Math.sin(t * 0.08) * 3; - // Occasional microwave/device interference spike - const spikeRaw = n.spike(t); - const interferenceSpike = spikeRaw > 0.7 ? (spikeRaw - 0.7) * 15 : 0; - // Subtle HVAC cycling - const hvacCycle = Math.sin(t * 0.4) * 0.5 + Math.sin(t * 1.1) * 0.2; - - const baseRssi = -45 + dayNightDrift + n.rssi(t) + interferenceSpike; - - const amplitude = new Float32Array(64); - for (let i = 0; i < 64; i++) { - // Base floor with harmonic variation per subcarrier - const subNoise = _harmonicNoise(t, i * 0.37, 2) * 0.02; - // Interference affects specific subcarrier bands (like a microwave in 2.4GHz) - const microBand = (i >= 20 && i <= 35) ? interferenceSpike * 0.03 : 0; - amplitude[i] = 0.1 + subNoise + microBand + Math.abs(hvacCycle) * 0.01; - } - - // Signal field with subtle ripple patterns (standing waves in empty room) - const vals = []; - for (let iz = 0; iz < 20; iz++) { - for (let ix = 0; ix < 20; ix++) { - const standingWave = Math.sin(ix * 0.8 + t * 0.3) * Math.sin(iz * 0.6 + t * 0.2) * 0.015; - const fieldNoise = _harmonicNoise(t + ix * 0.5 + iz * 0.7, ix + iz * 20, 2) * 0.008; - const ripple = interferenceSpike > 0 - ? _gaussian(Math.sqrt((ix - 10) ** 2 + (iz - 10) ** 2), 8) * interferenceSpike * 0.02 - : 0; - vals.push(_clamp(0.05 + standingWave + fieldNoise + ripple, 0, 1)); - } - } - - return this._baseFrame({ - nodes: [{ node_id: 1, rssi_dbm: baseRssi, position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }], - features: { - mean_rssi: baseRssi, - variance: 0.3 + Math.abs(n.env(t)) * 0.15 + interferenceSpike * 0.5, - std: 0.55 + interferenceSpike * 0.2, - motion_band_power: 0.02 + interferenceSpike * 0.08 + Math.abs(hvacCycle) * 0.005, - breathing_band_power: 0.01 + Math.abs(hvacCycle) * 0.003, - dominant_freq_hz: interferenceSpike > 0.5 ? 2.45 : 0.05 + Math.abs(hvacCycle) * 0.02, - spectral_power: 0.03 + interferenceSpike * 0.1, - }, - signal_field: { grid_size: [20, 1, 20], values: vals }, - edge_modules: { - environment: { - interference_detected: interferenceSpike > 0.5, - interference_band: interferenceSpike > 0.5 ? '2.4GHz_microwave' : 'none', - ambient_drift: dayNightDrift.toFixed(2), - }, - }, - }); - } - - // ======================================================================== - // 2. Single Breathing — HRV, respiratory sinus arrhythmia, natural irregularity - // ======================================================================== - - _singleBreathing(t) { - const n = _getNoiseBank('single_breathing'); - - // Natural breathing: ~16 BPM but with irregularity - // Breathing rate varies slightly over time (14.5-17.5) - const breathRateBase = 16 + _harmonicNoise(t, 1.23, 2) * 1.5; - const breathFreq = breathRateBase / 60; - // Accumulate phase for non-uniform period - const breathPhase = Math.sin(2 * Math.PI * breathFreq * t + n.breath(t) * 0.4); - // Inhale is slightly shorter than exhale (1:1.5 ratio via asymmetric wave) - const breathSignal = breathPhase > 0 - ? Math.sin(Math.asin(breathPhase) * 1.3) - : breathPhase * 0.85; - - // Heart Rate Variability (HRV): base 72 BPM, varies 68-76 - // Respiratory Sinus Arrhythmia: HR increases on inhale, decreases on exhale - const rsaEffect = breathSignal * 3.0; // +/-3 BPM with breathing - const hrvWander = _harmonicNoise(t, 7.77, 3) * 2.0; // slow HRV drift - const instantHR = 72 + rsaEffect + hrvWander + n.hr(t) * 0.5; - const hrFreq = instantHR / 60; - const hrPhase = Math.sin(2 * Math.PI * hrFreq * t); - - const amplitude = new Float32Array(64); - for (let i = 0; i < 64; i++) { - const subBase = 0.4 + 0.2 * Math.sin(t * 0.5 + i * 0.15); - const breathMod = breathSignal * 0.08 * (1 + 0.3 * Math.sin(i * 0.4)); // subcarrier-dependent - const hrMod = hrPhase * 0.015 * (i > 20 && i < 45 ? 1.5 : 0.5); // HR stronger in mid-band - amplitude[i] = subBase + breathMod + hrMod + _harmonicNoise(t, i * 0.13, 2) * 0.01; - } - - const rssiBase = -42 + breathSignal * 1.5 + n.rssi(t) * 0.5; - - return this._baseFrame({ - nodes: [{ node_id: 1, rssi_dbm: rssiBase, position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }], - features: { - mean_rssi: rssiBase, - variance: 1.8 + breathSignal * 0.3 + Math.abs(n.motion(t)) * 0.1, - std: 1.34 + Math.abs(breathSignal) * 0.1, - motion_band_power: 0.04 + Math.abs(breathSignal) * 0.02, - breathing_band_power: 0.12 + breathSignal * 0.04, - dominant_freq_hz: breathFreq, - spectral_power: 0.18 + Math.abs(hrPhase) * 0.03, - }, - classification: { motion_level: 'present_still', presence: true, confidence: 0.88 + breathSignal * 0.03 }, - signal_field: { grid_size: [20, 1, 20], values: this._presenceField(10, 10, 2.5, t) }, - vital_signs: { - breathing_rate_bpm: breathRateBase, - heart_rate_bpm: instantHR, - breathing_confidence: 0.85 + breathSignal * 0.05, - heart_rate_confidence: 0.75 + hrPhase * 0.05, - hrv_ms: 35 + _harmonicNoise(t, 3.14, 2) * 15, // RMSSD-like HRV metric - rsa_active: true, - }, - persons: [{ id: 'p0', position: [0 + n.pos1(t) * 0.05, 0, 0 + n.pos2(t) * 0.05], motion_score: 15, pose: 'standing', facing: 0 }], - estimated_persons: 1, - edge_modules: { - vital_trend: { status: 'normal', trend: 'stable', hrv_quality: 'good' }, - cardiac_detail: { rsa_amplitude_bpm: Math.abs(rsaEffect).toFixed(1), hrv_rmssd_ms: (35 + _harmonicNoise(t, 3.14, 2) * 15).toFixed(0) }, - }, - }); - } - - // ======================================================================== - // 3. Two Walking — collision avoidance, phone pause, confidence dip at crossing - // ======================================================================== - - _twoWalking(t) { - const n = _getNoiseBank('two_walking'); - - // Person 1: walks a figure-8 with speed variation - const p1speed = 0.5 + _harmonicNoise(t, 1.1, 2) * 0.1; // natural speed var - const p1phase = t * p1speed; - let p1x = Math.sin(p1phase) * 2.5; - let p1z = Math.sin(p1phase * 0.7) * Math.cos(p1phase * 0.35) * 1.8; - - // Person 2: walks an ellipse, pauses at t~10-12 (checking phone) - const phonePause = (t >= 10 && t < 12); - const p2speedMod = phonePause ? 0.05 : 1.0; // nearly stopped during phone check - const p2speed = (0.4 + _harmonicNoise(t, 2.2, 2) * 0.08) * p2speedMod; - const p2phase = t * 0.4 + 1 + (phonePause ? 0 : _harmonicNoise(t, 3.3, 2) * 0.1); - let p2x = -Math.sin(p2phase) * 2; - let p2z = Math.cos(p2phase * 0.75 + 2) * 1.5; - - // Collision avoidance: repulsion when persons are close - const dx = p1x - p2x; - const dz = p1z - p2z; - const dist = Math.sqrt(dx * dx + dz * dz); - const minDist = 0.8; - if (dist < minDist * 3 && dist > 0.01) { - const repulsion = Math.max(0, 1 - dist / (minDist * 3)) * 0.6; - const nx = dx / dist, nz = dz / dist; - p1x += nx * repulsion; - p1z += nz * repulsion; - p2x -= nx * repulsion; - p2z -= nz * repulsion; - } - - // Confidence dip when persons are close (tracking confusion) - const proxConfidence = dist < 1.5 ? 0.65 + dist * 0.1 : 0.82; - const matchConfidence = dist < 1.2 ? 0.6 + dist * 0.2 : 0.91; - - const p1facing = Math.atan2( - Math.cos(p1phase) * p1speed * 2.5, - Math.cos(p1phase * 0.7) * 0.7 * Math.cos(p1phase * 0.35) * 1.8 - ); - const p2facing = phonePause - ? Math.PI * 0.8 // looking down at phone - : Math.atan2(-Math.cos(p2phase) * p2speed * 2, -Math.sin(p2phase * 0.75 + 2) * 0.75 * 1.5); - - const p1ms = 160 + _harmonicNoise(t, 4.4, 2) * 20; - const p2ms = phonePause ? 8 + Math.abs(n.motion(t)) * 5 : 140 + _harmonicNoise(t, 5.5, 2) * 20; - - const amplitude = new Float32Array(64); - for (let i = 0; i < 64; i++) { - amplitude[i] = 0.3 + 0.3 * Math.abs(Math.sin(t * 2 + i * 0.3)) - + _harmonicNoise(t, i * 0.17, 2) * 0.02; - } - - return this._baseFrame({ - nodes: [{ node_id: 1, rssi_dbm: -40 + Math.sin(t * 1.2) * 4 + n.rssi(t), position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }], - features: { - mean_rssi: -40 + Math.sin(t * 1.2) * 4 + n.rssi(t), - variance: 3.5 + Math.sin(t * 0.8) * 1.2 + (dist < 1.5 ? 2 : 0), - std: 1.87 + (dist < 1.5 ? 0.5 : 0), - motion_band_power: 0.25 + Math.abs(Math.sin(t * 1.5)) * 0.15 * (phonePause ? 0.3 : 1), - breathing_band_power: 0.06, - dominant_freq_hz: 1.2 + Math.sin(t * 0.5) * 0.3, - spectral_power: 0.45, - }, - classification: { motion_level: phonePause ? 'present_still' : 'active', presence: true, confidence: proxConfidence }, - signal_field: { grid_size: [20, 1, 20], values: this._twoPresenceField(10 + p1x * 2, 10 + p1z * 2, 10 + p2x * 2, 10 + p2z * 2, t) }, - vital_signs: { breathing_rate_bpm: 18, heart_rate_bpm: 85, breathing_confidence: 0.4, heart_rate_confidence: 0.35 }, - persons: [ - { id: 'p0', position: [p1x, 0, p1z], motion_score: p1ms, pose: 'walking', facing: p1facing }, - { id: 'p1', position: [p2x, 0, p2z], motion_score: p2ms, pose: phonePause ? 'standing' : 'walking', facing: p2facing }, - ], - estimated_persons: 2, - edge_modules: { - person_match: { matched: 2, confidence: matchConfidence, proximity_warning: dist < 1.5 }, - tracking: { id_swap_risk: dist < 1.0, nearest_pair_dist: dist.toFixed(2) }, - }, - }); - } - - // ======================================================================== - // 4. Fall Event — pre-fall stumble, impact spike, micro-movements, shock HR - // ======================================================================== - - _fallEvent(t) { - const n = _getNoiseBank('fall_event'); - - // Timeline: 0-3 normal walk, 3-5 stumble, 5-5.8 fall, 5.8-8 micro-movement, 8+ still - const stumbleStart = 3, fallStart = 5, fallEnd = 5.8; - const microEnd = 8, stillPhase = t >= microEnd; - - const preStumble = t < stumbleStart; - const stumbling = t >= stumbleStart && t < fallStart; - const inFall = t >= fallStart && t < fallEnd; - const microMovement = t >= fallEnd && t < microEnd; - const postFall = t >= fallEnd; - - // Pre-fall stumble: unsteady gait (asymmetric, wobbly) - const stumbleIntensity = stumbling ? _smoothstep(stumbleStart, fallStart, t) : 0; - const wobble = stumbling ? Math.sin(t * 8) * stumbleIntensity * 0.4 : 0; - - // Fall impact spike: sharp gaussian at moment of impact - const impactT = (fallStart + fallEnd) / 2; - const impactSpike = Math.exp(-((t - impactT) ** 2) / 0.04) * 1.0; - - // Post-fall micro-movements (trying to get up) - const microIntensity = microMovement - ? (1 - _smoothstep(fallEnd, microEnd, t)) * 0.3 - : 0; - const microSignal = microMovement - ? Math.sin(t * 3) * microIntensity + Math.sin(t * 5.5) * microIntensity * 0.4 - : 0; - - // Heart rate: normal 72, elevated post-fall shock response 100-110 BPM - let hrRate = 72; - if (stumbling) hrRate = 72 + stumbleIntensity * 15; // anxiety rising - else if (inFall) hrRate = 90 + impactSpike * 30; - else if (postFall) hrRate = 108 - _smoothstep(fallEnd, fallEnd + 20, t) * 30; // slowly comes down - hrRate += n.hr(t) * 1.5; - - // Breathing: elevated post-fall - let breathRate = 16; - if (postFall) breathRate = 24 - _smoothstep(fallEnd, fallEnd + 15, t) * 8; - breathRate += n.breath(t) * 0.5; - - // Position: walking -> stumble -> fall -> ground - let px = 0.3, pz = 0.2, py = 0, pose = 'standing', ms = 20; - if (preStumble) { - px = Math.sin(t * 0.4) * 1.5; - pz = t * 0.3 - 1; - pose = 'walking'; - ms = 80; - } else if (stumbling) { - const st = (t - stumbleStart) / (fallStart - stumbleStart); - px = Math.sin(stumbleStart * 0.4) * 1.5 + wobble + st * 0.5; - pz = (stumbleStart * 0.3 - 1) + st * 0.3; - pose = 'walking'; // stumbling but still upright - ms = 120 + stumbleIntensity * 80; - } else if (inFall) { - pose = 'falling'; - ms = 255; - py = 0; - } else if (microMovement) { - pose = 'fallen'; - ms = _clamp(microIntensity * 100, 3, 40); - px += microSignal * 0.1; - } else { - pose = 'fallen'; - ms = 2 + Math.abs(n.motion(t)) * 1; - } - - const motionPower = preStumble ? 0.08 - : stumbling ? 0.15 + stumbleIntensity * 0.3 - : inFall ? 0.6 + impactSpike * 0.4 - : microMovement ? 0.05 + microIntensity * 0.15 - : 0.02 + Math.abs(n.motion(t)) * 0.005; - - const amplitude = new Float32Array(64); - for (let i = 0; i < 64; i++) { - const base = postFall && !microMovement ? 0.15 : 0.3; - amplitude[i] = base + impactSpike * 0.5 + microSignal * 0.1 - + Math.sin(t * 0.5 + i * 0.1) * 0.1 * (1 - (stillPhase ? 0.7 : 0)) - + _harmonicNoise(t, i * 0.19, 2) * 0.01; - } - - const rssi = -43 + impactSpike * 8 + wobble * 2 + n.rssi(t) * 0.8; - - return this._baseFrame({ - nodes: [{ node_id: 1, rssi_dbm: rssi, position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }], - features: { - mean_rssi: rssi, - variance: postFall && !microMovement ? 0.5 : (1.5 + impactSpike * 6 + stumbleIntensity * 2), - std: postFall && !microMovement ? 0.7 : (1.22 + impactSpike * 2), - motion_band_power: motionPower, - breathing_band_power: postFall ? 0.08 + Math.abs(n.breath(t)) * 0.02 : 0.1, - dominant_freq_hz: inFall ? 3.5 : (stumbling ? 1.8 + wobble : 0.15), - spectral_power: inFall ? 0.9 : (postFall ? 0.1 : 0.2 + stumbleIntensity * 0.3), - }, - classification: { - motion_level: postFall && !microMovement ? 'present_still' : (inFall || stumbling ? 'active' : 'present_still'), - presence: true, - confidence: inFall ? 0.55 : (stumbling ? 0.7 : (postFall ? 0.6 : 0.85)), - fall_detected: inFall || postFall, - pre_fall_warning: stumbling, - }, - signal_field: { grid_size: [20, 1, 20], values: this._presenceField(10 + px, 10 + pz, postFall ? 1.5 + microIntensity : 2.5, t) }, - vital_signs: { - breathing_rate_bpm: breathRate, - heart_rate_bpm: hrRate, - breathing_confidence: postFall ? 0.5 + _smoothstep(fallEnd, fallEnd + 5, t) * 0.2 : 0.8, - heart_rate_confidence: postFall ? 0.4 + _smoothstep(fallEnd, fallEnd + 5, t) * 0.2 : 0.7, - }, - persons: [{ id: 'p0', position: [px, py, pz], motion_score: ms, pose, facing: 0.5, fallProgress: inFall ? (t - fallStart) / (fallEnd - fallStart) : (postFall ? 1 : 0) }], - estimated_persons: 1, - edge_modules: { - fall_detect: { - detected: inFall || postFall, - severity: inFall ? 'critical' : (microMovement ? 'monitoring_movement' : (stillPhase ? 'monitoring_still' : 'none')), - impact_time: postFall ? (fallEnd - fallStart).toFixed(2) : (inFall ? (t - fallStart).toFixed(2) : '0'), - pre_fall_stumble: stumbling, - post_fall_movement: microMovement, - shock_hr_bpm: postFall ? hrRate.toFixed(0) : null, - }, - }, - }); - } - - // ======================================================================== - // 5. Sleep Monitoring — sleep stages, REM, position changes, apnea buildup - // ======================================================================== - - _sleepMonitoring(t) { - const n = _getNoiseBank('sleep_monitoring'); - - // Sleep stages timeline (30s cycle compressed): - // 0-4: light sleep (stage 1-2) - // 4-10: deep sleep (stage 3-4) - // 10-14: REM sleep - // 14-16: position change - // 16-18: light sleep again - // 18-22: apnea warning signs (breathing gets irregular) - // 22-26: apnea event - // 26-30: recovery - const cycleT = t % 30; - - let sleepStage = 'light'; - let breathRateBase = 14; - let movementLevel = 0.03; - let hrBase = 64; - let eyeMovementArtifact = 0; - let positionChangeActive = false; - - if (cycleT < 4) { - // Light sleep: more body movement, higher breath rate - sleepStage = 'light'; - breathRateBase = 14 + _harmonicNoise(t, 1.1, 2) * 1.5; - movementLevel = 0.06 + Math.abs(n.motion(t)) * 0.03; - hrBase = 64 + _harmonicNoise(t, 2.2, 2) * 3; - } else if (cycleT < 10) { - // Deep sleep: minimal movement, slow breathing, low HR - sleepStage = 'deep'; - breathRateBase = 10 + _harmonicNoise(t, 1.3, 2) * 0.5; - movementLevel = 0.01; - hrBase = 56 + _harmonicNoise(t, 2.4, 2) * 1; - } else if (cycleT < 14) { - // REM: rapid eye movement creates signal artifacts, HR more variable - sleepStage = 'REM'; - breathRateBase = 16 + _harmonicNoise(t, 1.5, 2) * 2; - movementLevel = 0.02; - hrBase = 68 + _harmonicNoise(t, 2.6, 3) * 5; // more variable in REM - // Eye movement artifact: high-frequency bursts - const remBurst = Math.sin(t * 12) * Math.sin(t * 7.3) * 0.5; - eyeMovementArtifact = Math.max(0, remBurst) * 0.08; - } else if (cycleT < 16) { - // Position change: brief movement spike - sleepStage = 'light'; - positionChangeActive = true; - const changeProgress = (cycleT - 14) / 2; - movementLevel = changeProgress < 0.5 - ? _smoothstep(0, 0.5, changeProgress) * 0.5 - : _smoothstep(1, 0.5, changeProgress) * 0.5; - breathRateBase = 16; - hrBase = 68; - } else if (cycleT < 18) { - sleepStage = 'light'; - breathRateBase = 13; - movementLevel = 0.04; - hrBase = 62; - } else if (cycleT < 22) { - // Pre-apnea: breathing becomes irregular - sleepStage = 'light'; - const irregularity = _smoothstep(18, 22, cycleT); - breathRateBase = 12 - irregularity * 6; // slowing down - // Breathing becomes chaotic before stopping - const chaotic = irregularity * Math.sin(t * 3 + Math.sin(t * 1.7) * 2) * 0.4; - breathRateBase = Math.max(3, breathRateBase + chaotic * 5); - movementLevel = 0.02; - hrBase = 60 - irregularity * 4; - } else if (cycleT < 26) { - // Full apnea - sleepStage = 'apnea'; - breathRateBase = 0 + Math.abs(n.breath(t)) * 0.5; // near-zero - movementLevel = 0.01; - hrBase = 54 + _smoothstep(22, 26, cycleT) * 8; // HR rises during apnea (stress) - } else { - // Recovery: gasp, then return to normal - sleepStage = 'light'; - const recovery = _smoothstep(26, 28, cycleT); - breathRateBase = 6 + recovery * 10; // gasping then normalizing - movementLevel = cycleT < 27 ? 0.15 : 0.04; // body startles - hrBase = 70 - recovery * 6; - } - - const inApnea = sleepStage === 'apnea'; - const breathFreq = breathRateBase / 60; - const breathPhase = Math.sin(2 * Math.PI * breathFreq * t + n.breath(t) * 0.3); - const breathSignal = inApnea ? n.breath(t) * 0.05 : breathPhase; - - // Lying position: slight shifts over time, bigger shift during position change - const posAngle = positionChangeActive - ? Math.PI / 2 + _smoothstep(14, 16, cycleT) * Math.PI * 0.3 - : Math.PI / 2 + Math.sin(t * 0.02) * 0.1; - const lyingX = 3.5 + (positionChangeActive ? Math.sin(cycleT * 2) * 0.3 : 0); - - const amplitude = new Float32Array(64); - for (let i = 0; i < 64; i++) { - const base = 0.25 + breathSignal * 0.04 * (1 - (inApnea ? 0.9 : 0)); - const rem = eyeMovementArtifact * (i > 30 && i < 50 ? 1.5 : 0.3); // REM artifact in upper band - amplitude[i] = base + rem + movementLevel * Math.sin(t * 0.8 + i * 0.1) - + _harmonicNoise(t, i * 0.11, 2) * 0.005; - } - - return this._baseFrame({ - nodes: [{ node_id: 1, rssi_dbm: -44 + breathSignal * 0.5 + n.rssi(t) * 0.3, position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }], - features: { - mean_rssi: -44 + breathSignal * 0.5 + n.rssi(t) * 0.3, - variance: inApnea ? 0.15 : (0.6 + movementLevel * 3), - std: inApnea ? 0.39 : (0.77 + movementLevel), - motion_band_power: movementLevel + eyeMovementArtifact * 0.5, - breathing_band_power: inApnea ? 0.02 : (0.1 + Math.abs(breathSignal) * 0.05), - dominant_freq_hz: breathFreq, - spectral_power: 0.08 + eyeMovementArtifact * 0.3, - }, - classification: { motion_level: movementLevel > 0.1 ? 'active' : 'present_still', presence: true, confidence: 0.9, apnea_detected: inApnea }, - signal_field: { grid_size: [20, 1, 20], values: this._presenceField(15, 13, 1.8 + movementLevel * 2, t) }, - vital_signs: { - breathing_rate_bpm: breathRateBase, - heart_rate_bpm: hrBase + n.hr(t) * 1, - breathing_confidence: inApnea ? 0.35 : (0.85 + breathSignal * 0.05), - heart_rate_confidence: 0.82, - }, - persons: [{ id: 'p0', position: [lyingX, 0.45, -3.5 + n.pos1(t) * 0.1], motion_score: _clamp(movementLevel * 100, 1, 50), pose: 'lying', facing: posAngle }], - estimated_persons: 1, - edge_modules: { - sleep_staging: { - stage: sleepStage, - stage_duration_s: cycleT.toFixed(1), - position_change: positionChangeActive, - rem_density: eyeMovementArtifact > 0.02 ? 'high' : 'low', - }, - sleep_apnea: { - state: inApnea ? 'apnea_event' : (cycleT >= 18 && cycleT < 22 ? 'pre_apnea_warning' : 'normal'), - duration_s: inApnea ? (cycleT - 22).toFixed(1) : 0, - events_total: inApnea ? 1 : 0, - breathing_irregularity: cycleT >= 18 && cycleT < 22 ? _smoothstep(18, 22, cycleT).toFixed(2) : '0', - }, - cardiac_arrhythmia: { rhythm: 'sinus', hr_variability: (4.2 + _harmonicNoise(t, 8.8, 2) * 2).toFixed(1) }, - }, - }); - } - - // ======================================================================== - // 6. Intrusion Detection — door pressure, cautious movement, drawer search - // ======================================================================== - - _intrusionDetect(t) { - const n = _getNoiseBank('intrusion_detect'); - - // Timeline: - // 0-2: baseline (quiet room) - // 2-3: door opens (pressure change, environmental shift) - // 3-6: cautious entry (pause-move-pause) - // 6-10: checking corners - // 10-14: checks room, pauses - // 14-22: searches drawers near desk (oscillating position) - // 22+: settles, loiters - - const doorOpen = t >= 2 && t < 3; - const entered = t >= 3; - const cautiousEntry = t >= 3 && t < 6; - const checkingCorners = t >= 6 && t < 10; - const settledSearch = t >= 14 && t < 22; - const loitering = t >= 22; - - // Environmental baseline shift when door opens - const doorPressure = doorOpen ? Math.sin((t - 2) * Math.PI) * 0.4 : 0; - - // Cautious movement: pause-move-pause pattern - let px, pz, facing, ms, pose; - if (!entered) { - px = -5.5; pz = -2; facing = 0; ms = 0; pose = 'absent'; - } else if (cautiousEntry) { - // Pause-move-pause pattern - const entryT = t - 3; - const movePhase = entryT % 1.5; - const isMoving = movePhase > 0.6 && movePhase < 1.3; // move for 0.7s, pause for 0.8s - const progress = Math.min(1, entryT / 3); - px = -4.5 + progress * 3; - pz = -1 + progress * 0.8; - // Slight position jitter during pauses (looking around) - if (!isMoving) { - px += Math.sin(t * 4) * 0.05; - facing = Math.sin(t * 2) * 0.5 + 0.8; // head scanning - } else { - facing = Math.atan2(3, 0.8); // heading into room - } - ms = isMoving ? 100 : 8; - pose = 'crouching'; - } else if (checkingCorners) { - // Move to corners, pause at each - const cornerT = (t - 6) / 4; - const cornerIdx = Math.floor(cornerT * 3) % 3; - const corners = [[-2, -0.5], [0, 1], [2, 0]]; - const corner = corners[cornerIdx]; - const inTransit = (cornerT * 3) % 1 < 0.6; - px = _lerp(corner[0], corners[(cornerIdx + 1) % 3][0], inTransit ? (cornerT * 3 % 1) / 0.6 : 0); - pz = _lerp(corner[1], corners[(cornerIdx + 1) % 3][1], inTransit ? (cornerT * 3 % 1) / 0.6 : 0); - facing = inTransit ? Math.atan2(corners[(cornerIdx + 1) % 3][0] - corner[0], corners[(cornerIdx + 1) % 3][1] - corner[1]) : Math.sin(t * 3) * Math.PI; // scanning while paused - ms = inTransit ? 120 : 10; - pose = 'crouching'; - } else if (settledSearch) { - // Oscillating near desk area, opening drawers - const searchT = t - 14; - const deskX = 1.5, deskZ = -0.5; - px = deskX + Math.sin(searchT * 1.2) * 0.6; // back and forth along desk - pz = deskZ + Math.cos(searchT * 0.8) * 0.3; - // Periodic reaching motion (drawer open/close every ~2s) - const reaching = Math.sin(searchT * Math.PI) > 0.7; - facing = reaching ? 0 : Math.PI * 0.5; - ms = reaching ? 80 : 30; - pose = reaching ? 'reaching' : 'standing'; - } else if (loitering) { - px = 0.5 + n.pos1(t) * 0.2; - pz = 0.5 + n.pos2(t) * 0.2; - facing = Math.sin(t * 0.3) * Math.PI; - ms = 12 + Math.abs(n.motion(t)) * 8; - pose = 'standing'; - } else { - // 10-14: general room check - const checkT = (t - 10) / 4; - px = -1 + Math.sin(checkT * Math.PI * 2) * 2; - pz = Math.cos(checkT * Math.PI * 2) * 1.5; - facing = Math.atan2(Math.cos(checkT * Math.PI * 2) * 2, -Math.sin(checkT * Math.PI * 2) * 1.5); - ms = 90; - pose = 'walking'; - } - - const isMovingNow = ms > 50; - const amplitude = new Float32Array(64); - for (let i = 0; i < 64; i++) { - amplitude[i] = entered - ? 0.35 + 0.15 * Math.sin(t * 1.5 + i * 0.2) + _harmonicNoise(t, i * 0.14, 2) * 0.01 - : 0.1 + doorPressure * 0.05 + _harmonicNoise(t, i * 0.14, 2) * 0.008; - } - - const rssiBase = entered ? -38 + Math.sin(t * 2) * 3 + n.rssi(t) : -46 + doorPressure * 2 + n.rssi(t) * 0.3; - - return this._baseFrame({ - nodes: [{ node_id: 1, rssi_dbm: rssiBase, position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }], - features: { - mean_rssi: rssiBase, - variance: isMovingNow ? 4.5 : (entered ? 1.0 : 0.2 + Math.abs(doorPressure) * 1.5), - std: isMovingNow ? 2.1 : 0.45, - motion_band_power: isMovingNow ? 0.4 : (entered ? 0.03 + (settledSearch ? 0.08 : 0) : 0.01 + Math.abs(doorPressure) * 0.15), - breathing_band_power: entered && !isMovingNow ? 0.08 : 0.01, - dominant_freq_hz: isMovingNow ? 1.8 : (doorOpen ? 0.5 : 0.1), - spectral_power: isMovingNow ? 0.55 : 0.03 + Math.abs(doorPressure) * 0.2, - }, - classification: { - motion_level: isMovingNow ? 'active' : (entered ? 'present_still' : 'absent'), - presence: entered || doorOpen, - confidence: entered ? 0.78 + (loitering ? 0.1 : 0) : (doorOpen ? 0.45 : 0.95), - intrusion: entered, - perimeter_breach: t >= 3 && t < 5, - door_event: doorOpen, - }, - signal_field: { grid_size: [20, 1, 20], values: entered ? this._presenceField(10 + px, 10 + pz, 2, t) : this._flatField(0.04 + Math.abs(doorPressure) * 0.06) }, - vital_signs: { - breathing_rate_bpm: entered && !isMovingNow ? 20 + n.breath(t) : 0, - heart_rate_bpm: entered ? 90 + _harmonicNoise(t, 4.4, 2) * 5 : 0, // elevated from adrenaline - breathing_confidence: entered && !isMovingNow ? 0.6 : 0, - heart_rate_confidence: entered && !isMovingNow ? 0.4 : 0, - }, - persons: entered ? [{ id: 'p0', position: [px, 0, pz], motion_score: ms, pose, facing }] : [], - estimated_persons: entered ? 1 : 0, - edge_modules: { - intrusion: { - detected: entered, - zone: cautiousEntry ? 'perimeter' : (settledSearch ? 'desk_area' : 'interior'), - threat_level: cautiousEntry ? 'high' : (settledSearch ? 'high' : (loitering ? 'medium' : 'none')), - behavior_pattern: cautiousEntry ? 'cautious_entry' : (checkingCorners ? 'corner_check' : (settledSearch ? 'searching' : (loitering ? 'loitering' : 'none'))), - }, - loitering: { detected: loitering || settledSearch, duration_s: loitering ? (t - 22).toFixed(1) : (settledSearch ? (t - 14).toFixed(1) : 0) }, - door_sensor: { open_event: doorOpen, pressure_delta: doorPressure.toFixed(3) }, - }, - }); - } - - // ======================================================================== - // 7. Gesture Control — distinct gesture signatures, recognition feedback - // ======================================================================== - - _gestureControl(t) { - const n = _getNoiseBank('gesture_control'); - - const gestureCycle = 7; // seconds per gesture - const gesturePhase = Math.floor(t / gestureCycle) % 4; - const gestures = ['wave', 'swipe_left', 'circle', 'point']; - const gestureT = t % gestureCycle; - const isGesturing = gestureT >= 1.5 && gestureT < 5; - const gestureProgress = isGesturing ? (gestureT - 1.5) / 3.5 : 0; - const gestureEnvelope = isGesturing ? Math.sin(gestureProgress * Math.PI) : 0; - - // Recognition feedback: brief confidence spike when gesture completes (at ~80% progress) - const recognitionMoment = isGesturing && gestureProgress > 0.75 && gestureProgress < 0.85; - const recognitionBoost = recognitionMoment ? 0.15 : 0; - - // Gesture-specific signal characteristics - let gestureSignal = 0; - let dominantFreq = 0.2; - let motionScore = 10; - let gestureDetail = {}; - - const g = gestures[gesturePhase]; - if (isGesturing) { - switch (g) { - case 'wave': - // Fast oscillation (hand waving back and forth) - gestureSignal = Math.sin(t * 14) * gestureEnvelope * 0.5 - + Math.sin(t * 21) * gestureEnvelope * 0.2; // harmonics - dominantFreq = 4.0 + _harmonicNoise(t, 6.6, 2) * 0.3; - motionScore = 150 * gestureEnvelope; - gestureDetail = { oscillation_hz: 7, amplitude: gestureEnvelope.toFixed(2) }; - break; - case 'swipe_left': - // Clear directional shift: signal ramps in one direction - gestureSignal = (gestureProgress - 0.5) * 2 * gestureEnvelope * 0.6; - dominantFreq = 2.0; - motionScore = 180 * gestureEnvelope; - gestureDetail = { direction: 'left', displacement: gestureSignal.toFixed(3) }; - break; - case 'circle': - // Rotating phase pattern - const circleAngle = gestureProgress * Math.PI * 2 * 1.5; // 1.5 rotations - gestureSignal = Math.sin(circleAngle) * gestureEnvelope * 0.4; - const phaseRotation = Math.cos(circleAngle) * gestureEnvelope * 0.4; - dominantFreq = 3.0; - motionScore = 130 * gestureEnvelope; - gestureDetail = { rotation_angle: (circleAngle * 180 / Math.PI).toFixed(0), phase_i: gestureSignal.toFixed(3), phase_q: phaseRotation.toFixed(3) }; - break; - case 'point': - // Quick, decisive: sharp onset, brief hold, sharp offset - const pointEnvelope = gestureProgress < 0.2 - ? _smoothstep(0, 0.2, gestureProgress) // fast rise - : (gestureProgress < 0.6 ? 1.0 : _smoothstep(1, 0.6, gestureProgress)); // hold then drop - gestureSignal = pointEnvelope * 0.55; - dominantFreq = 1.5; // lower freq, more impulse-like - motionScore = 200 * pointEnvelope; - gestureDetail = { sharpness: pointEnvelope > 0.9 ? 'locked' : 'transitioning' }; - break; - } - } - - const amplitude = new Float32Array(64); - for (let i = 0; i < 64; i++) { - const base = 0.3 + _harmonicNoise(t, i * 0.13, 2) * 0.01; - // Each gesture affects subcarriers differently - let gestMod = 0; - if (isGesturing) { - if (g === 'wave') gestMod = Math.sin(t * 14 + i * 0.5) * gestureEnvelope * 0.15; - else if (g === 'swipe_left') gestMod = gestureSignal * (i / 64) * 0.2; // gradient across band - else if (g === 'circle') gestMod = Math.sin(t * 8 + i * 0.3) * gestureEnvelope * 0.12; - else if (g === 'point') gestMod = gestureSignal * 0.2 * (i > 25 && i < 40 ? 1.5 : 0.5); - } - amplitude[i] = base + gestMod; - } - - const rssi = -41 + gestureEnvelope * 3 + n.rssi(t) * 0.5; - const confidence = isGesturing ? 0.7 + gestureEnvelope * 0.15 + recognitionBoost : 0; - - return this._baseFrame({ - nodes: [{ node_id: 1, rssi_dbm: rssi, position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }], - features: { - mean_rssi: rssi, - variance: 1.2 + gestureEnvelope * 2.5, - std: 1.1 + gestureEnvelope * 0.5, - motion_band_power: 0.05 + Math.abs(gestureSignal) * 0.6, - breathing_band_power: 0.08, - dominant_freq_hz: dominantFreq, - spectral_power: 0.15 + gestureEnvelope * 0.4, - }, - classification: { - motion_level: isGesturing ? 'active' : 'present_still', - presence: true, - confidence: 0.85, - gesture: isGesturing ? g : null, - gesture_recognized: recognitionMoment, - }, - signal_field: { grid_size: [20, 1, 20], values: this._presenceField(10, 10, 2 + gestureEnvelope * 0.8, t) }, - vital_signs: { breathing_rate_bpm: 16 + n.breath(t) * 0.5, heart_rate_bpm: 74 + n.hr(t) * 1, breathing_confidence: 0.7, heart_rate_confidence: 0.65 }, - persons: [{ id: 'p0', position: [0 + n.pos1(t) * 0.03, 0, 0.5 + n.pos2(t) * 0.03], motion_score: motionScore, pose: 'gesturing', facing: Math.PI, gestureType: g, gestureIntensity: gestureEnvelope, gestureDetail }], - estimated_persons: 1, - edge_modules: { - gesture: { - detected: isGesturing, - type: isGesturing ? g : 'none', - confidence: confidence, - dtw_distance: isGesturing ? (12.3 - recognitionBoost * 8) : 999, - recognition_feedback: recognitionMoment ? 'MATCHED' : null, - detail: gestureDetail, - }, - }, - }); - } - - // ======================================================================== - // 8. Crowd Occupancy — clustering, stationary person, rushing, entry/exit - // ======================================================================== - - _crowdOccupancy(t) { - const n = _getNoiseBank('crowd_occupancy'); - - // Points of interest for clustering - const poi = [ - { x: -2, z: -1.5, label: 'display' }, // display/kiosk - { x: 2, z: 1, label: 'counter' }, // service counter - { x: 0, z: 0, label: 'center' }, // open area - ]; - - // 5 people with distinct behaviors - const persons = []; - const count = 5; - - // Person 0: sits stationary at desk - { - const px = -1.5 + n.pos1(t) * 0.02; - const pz = 1.5 + n.pos2(t) * 0.02; - persons.push({ id: 'p0', position: [px, 0, pz], motion_score: 3, pose: 'sitting', facing: Math.PI * 0.5 + _harmonicNoise(t, 11.1, 2) * 0.1 }); - } - - // Person 1: browses near display (clusters near POI 0) - { - const browseT = t * 0.3; - const px = poi[0].x + Math.sin(browseT) * 0.8 + _harmonicNoise(t, 12.1, 2) * 0.1; - const pz = poi[0].z + Math.cos(browseT * 0.7) * 0.6; - const facing = Math.atan2(poi[0].x - px, poi[0].z - pz); - persons.push({ id: 'p1', position: [px, 0, pz], motion_score: 40 + Math.abs(_harmonicNoise(t, 13.1, 2)) * 20, pose: 'walking', facing }); - } - - // Person 2: rushes through (faster speed, enters and exits) - { - const rushCycle = 20; - const rushT = t % rushCycle; - const inSpace = rushT > 2 && rushT < 15; - const rushProgress = inSpace ? (rushT - 2) / 13 : 0; - const rushSpeed = 1.5 + _harmonicNoise(t, 14.1, 2) * 0.2; - const px = inSpace ? -4 + rushProgress * 8 : -5; - const pz = inSpace ? -0.5 + Math.sin(rushProgress * Math.PI * 0.5) * 0.8 : -3; - if (inSpace) { - persons.push({ id: 'p2', position: [px, 0, pz], motion_score: 220, pose: 'walking', facing: 0.1, speed: rushSpeed }); - } - } - - // Person 3: walks between display and counter (clusters near POIs) - { - const walkT = t * 0.15; - const poiIdx = Math.floor(walkT) % 2; - const target = poiIdx === 0 ? poi[0] : poi[1]; - const progress = walkT % 1; - const other = poiIdx === 0 ? poi[1] : poi[0]; - const px = _lerp(other.x, target.x, _smoothstep(0, 0.7, progress)) - + _harmonicNoise(t, 15.1, 2) * 0.15; - const pz = _lerp(other.z, target.z, _smoothstep(0, 0.7, progress)) - + _harmonicNoise(t, 16.1, 2) * 0.15; - const facing = Math.atan2(target.x - other.x, target.z - other.z); - const nearPoi = progress > 0.7; - persons.push({ id: 'p3', position: [px, 0, pz], motion_score: nearPoi ? 15 : 100, pose: nearPoi ? 'standing' : 'walking', facing }); - } - - // Person 4: enters/exits periodically - { - const cycleLen = 25; - const ct = t % cycleLen; - const entering = ct < 3; - const inside = ct >= 3 && ct < 18; - const exiting = ct >= 18 && ct < 21; - if (entering || inside || exiting) { - let px, pz; - if (entering) { - px = -4.5 + (ct / 3) * 3; - pz = -2 + (ct / 3) * 1; - } else if (exiting) { - const ep = (ct - 18) / 3; - px = 1 + ep * 3; - pz = 0.5 + ep * 1; - } else { - px = poi[2].x + Math.sin(t * 0.2) * 1.5; - pz = poi[2].z + Math.cos(t * 0.15) * 1; - } - persons.push({ id: 'p4', position: [px, 0, pz], motion_score: (entering || exiting) ? 130 : 70, pose: 'walking', facing: entering ? 0.3 : (exiting ? -0.3 : Math.atan2(Math.cos(t * 0.2), -Math.sin(t * 0.15))) }); - } - } - - const actualCount = persons.length; - - const amplitude = new Float32Array(64); - for (let i = 0; i < 64; i++) { - amplitude[i] = 0.35 + 0.25 * Math.abs(Math.sin(t * 1.5 + i * 0.2)) - + _harmonicNoise(t, i * 0.16, 2) * 0.015; - } - - // Signal field with congestion patterns around POIs - const vals = []; - for (let iz = 0; iz < 20; iz++) { - for (let ix = 0; ix < 20; ix++) { - let v = 0; - for (const p of persons) { - const dx = (ix - 10) / 3 - p.position[0]; - const dz = (iz - 10) / 3 - p.position[2]; - v += _gaussian(Math.sqrt(dx * dx + dz * dz), 0.9) * 0.4; - } - // POI congestion haze - for (const p of poi) { - const dx = (ix - 10) / 3 - p.x; - const dz = (iz - 10) / 3 - p.z; - v += _gaussian(Math.sqrt(dx * dx + dz * dz), 2.0) * 0.08; - } - vals.push(_clamp(v + _harmonicNoise(t + ix * 0.3, iz * 0.4, 2) * 0.01, 0, 1)); - } - } - - return this._baseFrame({ - nodes: [{ node_id: 1, rssi_dbm: -37 + Math.sin(t * 0.9) * 5 + n.rssi(t), position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }], - features: { - mean_rssi: -37 + Math.sin(t * 0.9) * 5 + n.rssi(t), - variance: 5.2 + Math.sin(t * 0.6) * 1.5 + actualCount * 0.3, - std: 2.28 + actualCount * 0.1, - motion_band_power: 0.25 + actualCount * 0.05, - breathing_band_power: 0.04, - dominant_freq_hz: 1.5, - spectral_power: 0.45 + actualCount * 0.03, - }, - classification: { motion_level: 'active', presence: true, confidence: 0.76 - (actualCount > 4 ? 0.05 : 0) }, - signal_field: { grid_size: [20, 1, 20], values: vals }, - vital_signs: { breathing_rate_bpm: 0, heart_rate_bpm: 0, breathing_confidence: 0.15, heart_rate_confidence: 0.1 }, - persons, - estimated_persons: actualCount, - edge_modules: { - occupancy: { - count: actualCount, - zones: { - display: persons.filter(p => Math.sqrt((p.position[0] - poi[0].x) ** 2 + (p.position[2] - poi[0].z) ** 2) < 2).length, - counter: persons.filter(p => Math.sqrt((p.position[0] - poi[1].x) ** 2 + (p.position[2] - poi[1].z) ** 2) < 2).length, - center: persons.filter(p => Math.sqrt((p.position[0] - poi[2].x) ** 2 + (p.position[2] - poi[2].z) ** 2) < 2).length, - }, - density: (actualCount / 20).toFixed(2), // per sq meter - congestion_zones: actualCount > 3 ? ['display'] : [], - }, - customer_flow: { - entries: Math.floor(t / 25) + 1, - exits: Math.floor(t / 25), - dwell_avg_s: 145 + _harmonicNoise(t, 17.1, 2) * 20, - }, - }, - }); - } - - // ======================================================================== - // 9. Search & Rescue — scanning, false positives, triangulation, gradual lock-on - // ======================================================================== - - _searchRescue(t) { - const n = _getNoiseBank('search_rescue'); - - // Timeline: - // 0-4: scanning phase (signal sweeps, no detection) - // 4-7: first false positive (ghost echo) - // 7-10: second scan, another brief false positive - // 10-14: genuine signal detected, gradual lock-on - // 14-20: confirmed detection, vital extraction (confidence building) - // 20+: stable monitoring - - const scanning = t < 4; - const falsePos1 = t >= 4 && t < 7; - const scan2 = t >= 7 && t < 10; - const falsePos2 = t >= 7 && t < 8.5; - const genuineDetect = t >= 10; - const lockingOn = t >= 10 && t < 14; - const confirmed = t >= 14; - const stableMonitor = t >= 20; - - // Scan sweep effect (nodes cycle through angles) - const scanAngle = t * 0.8; - - // Triangulation: 3 sensor nodes with different signal strengths - const targetPos = [3.5, 0, 0]; - const nodePositions = [[2, 0, 1.5], [-2, 0, 1.5], [0, 0, -2]]; - const nodes = []; - - for (let ni = 0; ni < 3; ni++) { - const npos = nodePositions[ni]; - const dist = Math.sqrt((npos[0] - targetPos[0]) ** 2 + (npos[2] - targetPos[2]) ** 2); - const baseSignal = -62 - dist * 3; // signal attenuation with distance - const scanMod = scanning ? Math.sin(scanAngle + ni * 2.1) * 4 : 0; - const falseSignal = (falsePos1 && ni === 0) ? Math.sin((t - 4) * 3) * 3 : 0; - const genuineSignal = genuineDetect ? _smoothstep(10, 14, t) * 5 : 0; - - const amp = new Float32Array(64); - for (let i = 0; i < 64; i++) { - amp[i] = 0.08 + (genuineDetect ? 0.06 * _smoothstep(10, 16, t) : 0) - * Math.sin(t * 0.4 + i * 0.15 + ni) - + _harmonicNoise(t, i * 0.12 + ni * 100, 2) * 0.008; - } - - nodes.push({ - node_id: ni + 1, - rssi_dbm: baseSignal + scanMod + falseSignal + genuineSignal + n.rssi(t) * 0.5, - position: npos, - amplitude: amp, - subcarrier_count: 64, - distance_estimate: genuineDetect ? (dist + (1 - _smoothstep(10, 20, t)) * 3).toFixed(2) : null, - }); - } - - // Confidence builds gradually during lock-on - let confidence; - if (scanning) confidence = 0.08 + Math.abs(n.motion(t)) * 0.05; - else if (falsePos1) confidence = 0.25 + Math.sin((t - 4) * 2) * 0.15; // fluctuating - else if (scan2 && !falsePos2) confidence = 0.1; - else if (falsePos2) confidence = 0.2 + Math.sin((t - 7) * 3) * 0.1; - else if (lockingOn) confidence = 0.2 + _smoothstep(10, 14, t) * 0.3; - else if (confirmed && !stableMonitor) confidence = 0.5 + _smoothstep(14, 20, t) * 0.2; - else confidence = 0.7 + n.env(t) * 0.03; - - // Vital sign extraction: gradual confidence over 10+ seconds after detection - const vitalConfidence = confirmed ? _smoothstep(14, 25, t) : 0; - const breathRate = genuineDetect ? 10 + _harmonicNoise(t, 3.3, 2) * 0.5 : 0; - const breathPhase = Math.sin(2 * Math.PI * (breathRate / 60) * t); - - // Detected persons - let detected = false; - let triageColor = 'unknown'; - if (falsePos1) { detected = true; triageColor = 'unknown'; } - else if (falsePos2) { detected = true; triageColor = 'unknown'; } - else if (genuineDetect) { detected = true; triageColor = confidence > 0.5 ? 'yellow' : 'unknown'; } - - const persons = detected && genuineDetect - ? [{ id: 'p0', position: targetPos, motion_score: 2, pose: 'lying', facing: 0, signal_strength: confidence }] - : (detected ? [{ id: 'ghost', position: [falsePos1 ? 1 : -1, 0, falsePos2 ? 2 : -1], motion_score: 1, pose: 'unknown', facing: 0 }] : []); - - return this._baseFrame({ - nodes, - features: { - mean_rssi: nodes[0].rssi_dbm, - variance: genuineDetect ? 0.4 + breathPhase * 0.1 * vitalConfidence : 0.15, - std: 0.63, - motion_band_power: 0.01 + (scanning ? Math.abs(Math.sin(scanAngle)) * 0.02 : 0), - breathing_band_power: genuineDetect ? 0.05 * vitalConfidence + breathPhase * 0.02 * vitalConfidence : 0.005, - dominant_freq_hz: genuineDetect && vitalConfidence > 0.3 ? 0.167 : (scanning ? 0.8 : 0.02), - spectral_power: 0.04 + (scanning ? 0.03 : 0), - }, - classification: { - motion_level: genuineDetect ? 'present_still' : 'absent', - presence: detected, - confidence, - through_wall: true, - triage_color: triageColor, - false_positive: (falsePos1 || falsePos2) && !genuineDetect, - scan_phase: scanning ? 'sweeping' : (lockingOn ? 'locking_on' : (confirmed ? 'confirmed' : 'searching')), - }, - signal_field: { grid_size: [20, 1, 20], values: this._searchRescueField(t, scanning, genuineDetect, confidence) }, - vital_signs: { - breathing_rate_bpm: genuineDetect && vitalConfidence > 0.2 ? breathRate : 0, - heart_rate_bpm: genuineDetect && vitalConfidence > 0.4 ? 55 + n.hr(t) * 2 : 0, - breathing_confidence: genuineDetect ? vitalConfidence * 0.85 : 0, - heart_rate_confidence: genuineDetect ? vitalConfidence * 0.5 : 0, - }, - persons, - estimated_persons: detected ? 1 : 0, - edge_modules: { - wifi_mat: { - mode: scanning ? 'scanning' : (lockingOn ? 'locking_on' : (confirmed ? 'monitoring' : 'search')), - survivors_detected: genuineDetect ? 1 : 0, - triage: genuineDetect && confidence > 0.5 ? 'delayed' : 'searching', - signal_through_material: 'concrete_30cm', - false_positives_filtered: (falsePos1 || falsePos2) ? 1 : 0, - triangulation_nodes: genuineDetect ? 3 : 0, - vital_extraction_confidence: (vitalConfidence * 100).toFixed(0) + '%', - }, - }, - }); - } - - // ======================================================================== - // 10. Elderly Care — gait asymmetry, gradual transitions, rest & recover - // ======================================================================== - - _elderlyCare(t) { - const n = _getNoiseBank('elderly_care'); - - // Timeline: - // 0-12: walking with gait analysis - // 12-14: slowing down - // 14-16: reaching for chair (transitional) - // 16-18: sitting transition - // 18-24: resting (HR comes down) - // 24+: light activity while seated - - const walkPhase = t < 12; - const slowingDown = t >= 12 && t < 14; - const reachingChair = t >= 14 && t < 16; - const sittingTransition = t >= 16 && t < 18; - const resting = t >= 18 && t < 24; - const seated = t >= 18; - - // Walking speed decreases gradually - const walkSpeed = walkPhase ? 0.6 - _smoothstep(8, 12, t) * 0.2 : (slowingDown ? 0.3 * (1 - _smoothstep(12, 14, t)) : 0); - - // Gait analysis: slight asymmetry in step timing (right step ~5% longer) - const stepFreq = walkPhase ? 1.4 + _harmonicNoise(t, 1.1, 2) * 0.05 : 0; - const stepPhaseR = Math.sin(2 * Math.PI * stepFreq * t); - const stepPhaseL = Math.sin(2 * Math.PI * stepFreq * t + Math.PI + 0.15); // asymmetry - const stepAsymmetry = Math.abs(stepPhaseR) - Math.abs(stepPhaseL); - - // Position - let px, pz, facing, ms, pose; - if (walkPhase) { - const wp = t * walkSpeed; - px = Math.sin(wp * 0.25) * 2; - pz = Math.cos(wp * 0.15) * 1.2; - facing = Math.atan2(Math.cos(wp * 0.25) * 0.25 * walkSpeed, -Math.sin(wp * 0.15) * 0.15 * walkSpeed); - ms = 60 + stepAsymmetry * 10; - pose = 'walking'; - } else if (slowingDown) { - const sp = _smoothstep(12, 14, t); - px = _lerp(Math.sin(12 * 0.6 * 0.25) * 2, 1, sp); - pz = _lerp(Math.cos(12 * 0.6 * 0.15) * 1.2, -1.5, sp); - facing = Math.atan2(1 - px, -1.5 - pz); - ms = 30 * (1 - sp); - pose = 'walking'; - } else if (reachingChair) { - const rp = _smoothstep(14, 16, t); - px = 1 + Math.sin(t * 2) * 0.05 * (1 - rp); // slight unsteadiness reaching - pz = -1.5; - facing = Math.PI * 0.25; - ms = 20 * (1 - rp); - pose = 'reaching'; - } else if (sittingTransition) { - const sp = _smoothstep(16, 18, t); - px = 1; - pz = -1.5; - facing = Math.PI * 0.25; - ms = 15 * (1 - sp); - pose = sp > 0.5 ? 'sitting' : 'reaching'; - } else { - px = 1 + n.pos1(t) * 0.02; - pz = -1.5 + n.pos2(t) * 0.02; - facing = Math.PI * 0.25 + _harmonicNoise(t, 5.5, 2) * 0.1; - ms = 5 + Math.abs(n.motion(t)) * 3; - pose = 'sitting'; - } - - // Heart rate: walking ~82, elevated slightly from walking exertion, - // then gradually comes down during rest (physiological recovery) - let hrBase; - if (walkPhase) hrBase = 82 + walkSpeed * 5; - else if (slowingDown || reachingChair) hrBase = 78 - _smoothstep(12, 16, t) * 5; - else if (resting) hrBase = 73 - _smoothstep(18, 24, t) * 5; // slow recovery - else hrBase = 68; - hrBase += n.hr(t) * 1.5; - - // Breathing: correlated with HR - let breathRate; - if (walkPhase) breathRate = 18 + walkSpeed * 3; - else if (seated) breathRate = 14 - _smoothstep(18, 24, t) * 2; - else breathRate = 16; - breathRate += n.breath(t) * 0.5; - - // Blood pressure proxy: HR/breathing correlation - const hrBreathRatio = hrBase / breathRate; - - const amplitude = new Float32Array(64); - for (let i = 0; i < 64; i++) { - amplitude[i] = 0.3 + (walkPhase ? 0.15 : 0.06) * Math.sin(t * 0.8 + i * 0.12) - + stepAsymmetry * 0.02 - + _harmonicNoise(t, i * 0.11, 2) * 0.008; - } - - return this._baseFrame({ - nodes: [{ node_id: 1, rssi_dbm: -41 + Math.sin(t * 0.5) * 2 + n.rssi(t) * 0.5, position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }], - features: { - mean_rssi: -41 + Math.sin(t * 0.5) * 2 + n.rssi(t) * 0.5, - variance: walkPhase ? 2.2 + stepAsymmetry * 0.5 : 0.8, - std: walkPhase ? 1.48 : 0.89, - motion_band_power: walkPhase ? 0.15 + Math.abs(stepPhaseR) * 0.05 : (ms > 10 ? 0.05 : 0.02), - breathing_band_power: 0.1 + Math.abs(n.breath(t)) * 0.02, - dominant_freq_hz: walkPhase ? stepFreq * 0.5 : 0.23, - spectral_power: 0.22, - }, - classification: { motion_level: walkPhase ? 'active' : (ms > 15 ? 'active' : 'present_still'), presence: true, confidence: 0.88 }, - signal_field: { grid_size: [20, 1, 20], values: this._presenceField(10 + px * 2, 10 + pz * 2, 2.5, t) }, - vital_signs: { - breathing_rate_bpm: breathRate, - heart_rate_bpm: hrBase, - breathing_confidence: 0.82, - heart_rate_confidence: 0.78, - hr_breath_ratio: hrBreathRatio.toFixed(2), - }, - persons: [{ id: 'p0', position: [px, 0, pz], motion_score: ms, pose, facing }], - estimated_persons: 1, - edge_modules: { - gait_analysis: { - step_frequency: walkPhase ? stepFreq.toFixed(2) : 0, - stride_length_m: walkPhase ? (0.48 + _harmonicNoise(t, 7.7, 2) * 0.02).toFixed(3) : 0, - symmetry: walkPhase ? (0.82 + stepAsymmetry * 0.1).toFixed(3) : null, - asymmetry_side: walkPhase ? 'right_longer' : null, - fall_risk: walkPhase ? 'low' : 'none', - }, - vital_trend: { - status: 'normal', - hr_trend: resting ? 'recovering' : (walkPhase ? 'elevated' : 'stable'), - recovery_phase: resting, - bp_proxy: hrBreathRatio > 5.5 ? 'elevated' : 'normal', - }, - pattern_sequence: { - activity: walkPhase ? 'walking' : (reachingChair || sittingTransition ? 'transitioning' : 'resting'), - transition: reachingChair ? 'reaching_chair' : (sittingTransition ? 'sitting_down' : (slowingDown ? 'slowing' : null)), - routine_deviation: false, - }, - }, - }); - } - - // ======================================================================== - // 11. Fitness Tracking — warm-up, intensity ramp, rest intervals, HR lag - // ======================================================================== - - _fitnessTracking(t) { - const n = _getNoiseBank('fitness_tracking'); - - // Timeline: - // 0-3: warm-up (slow movements, gradually increasing) - // 3-9: jumping jacks (high intensity) - // 9-12: rest interval - // 12-18: squats (medium intensity) - // 18-21: rest interval - // 21-27: jumping jacks again (peak intensity) - // 27-30: cool-down - - const block = t % 30; - let exerciseType = 'rest'; - let targetIntensity = 0; // 0-1 target exertion - let actualMotion = 0; - - if (block < 3) { - // Warm-up: ramp from 0 to 0.4 - exerciseType = 'warmup'; - targetIntensity = _smoothstep(0, 3, block) * 0.4; - actualMotion = targetIntensity * 0.8; - } else if (block < 9) { - // Jumping jacks - exerciseType = 'jumping_jacks'; - targetIntensity = 0.7 + _smoothstep(3, 5, block) * 0.2; - // Rhythmic motion with 2 Hz cadence - actualMotion = targetIntensity * (0.7 + 0.3 * Math.abs(Math.sin(t * Math.PI * 2))); - } else if (block < 12) { - // Rest - exerciseType = 'rest'; - targetIntensity = 0.1 * (1 - _smoothstep(9, 11, block)); - actualMotion = 0.05 + Math.abs(n.motion(t)) * 0.03; // slight fidgeting - } else if (block < 18) { - // Squats: slower, deeper movement - exerciseType = 'squats'; - targetIntensity = 0.6 + _smoothstep(12, 14, block) * 0.15; - // Slower cadence (~0.5 Hz), smooth up/down - const squatPhase = Math.sin(t * Math.PI * 0.5); - actualMotion = targetIntensity * (0.5 + 0.5 * Math.abs(squatPhase)); - } else if (block < 21) { - // Rest - exerciseType = 'rest'; - targetIntensity = 0.1 * (1 - _smoothstep(18, 20, block)); - actualMotion = 0.05; - } else if (block < 27) { - // Jumping jacks peak - exerciseType = 'jumping_jacks'; - targetIntensity = 0.85 + _smoothstep(21, 23, block) * 0.15; - actualMotion = targetIntensity * (0.7 + 0.3 * Math.abs(Math.sin(t * Math.PI * 2.2))); - } else { - // Cool-down - exerciseType = 'cooldown'; - targetIntensity = 0.3 * (1 - _smoothstep(27, 30, block)); - actualMotion = targetIntensity * 0.5; - } - - // HR lags behind exertion by ~5-8 seconds (physiological delay) - // Simulate with a slow-tracking variable - const hrTarget = 70 + targetIntensity * 90; // 70 rest -> 160 max - // IIR-filtered HR that follows target with delay - const hrLagFactor = 0.92; // higher = more lag - const hrDelayed = hrTarget + (70 - hrTarget) * Math.exp(-t * 0.15) * (exerciseType === 'rest' ? 0.5 : 0.2); - // Use harmonic noise to approximate the lag behavior in a stateless way - const hrSmooth = hrTarget - _harmonicNoise(t - 3, 8.8, 2) * 5 * targetIntensity; - const hrRate = _clamp(hrSmooth + n.hr(t) * 2, 60, 185); - - // Breathing also lags but less - const breathTarget = 14 + targetIntensity * 20; - const breathRate = _clamp(breathTarget + n.breath(t) * 1.5 - _harmonicNoise(t - 1, 9.9, 2) * 2, 12, 40); - - const repCount = Math.floor(t * (exerciseType === 'jumping_jacks' ? 1.0 : (exerciseType === 'squats' ? 0.25 : 0))); - - // Vertical motion for exercises - const verticalPos = exerciseType === 'jumping_jacks' - ? Math.abs(Math.sin(t * Math.PI * 2)) * 0.15 - : (exerciseType === 'squats' ? -Math.abs(Math.sin(t * Math.PI * 0.5)) * 0.3 : 0); - - const amplitude = new Float32Array(64); - for (let i = 0; i < 64; i++) { - amplitude[i] = 0.35 + 0.3 * actualMotion * Math.abs(Math.sin(t * 2.5 + i * 0.25)) - + _harmonicNoise(t, i * 0.15, 2) * 0.01; - } - - const ms = _clamp(actualMotion * 255, 5, 255); - - return this._baseFrame({ - nodes: [{ node_id: 1, rssi_dbm: -39 + actualMotion * 6 + n.rssi(t), position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }], - features: { - mean_rssi: -39 + actualMotion * 6 + n.rssi(t), - variance: 2 + actualMotion * 4, - std: 1.4 + actualMotion * 1.5, - motion_band_power: 0.05 + actualMotion * 0.55, - breathing_band_power: 0.08 + targetIntensity * 0.1, - dominant_freq_hz: exerciseType === 'jumping_jacks' ? 2.0 : (exerciseType === 'squats' ? 0.5 : 0.2), - spectral_power: 0.1 + actualMotion * 0.5, - }, - classification: { motion_level: actualMotion > 0.3 ? 'active' : (actualMotion > 0.1 ? 'present_still' : 'present_still'), presence: true, confidence: 0.8 }, - signal_field: { grid_size: [20, 1, 20], values: this._presenceField(10, 10, 2.5 + actualMotion * 1.5, t) }, - vital_signs: { - breathing_rate_bpm: breathRate, - heart_rate_bpm: hrRate, - breathing_confidence: 0.7, - heart_rate_confidence: 0.65, - hr_zone: hrRate > 155 ? 'anaerobic' : (hrRate > 130 ? 'threshold' : (hrRate > 110 ? 'aerobic' : 'warmup')), - }, - persons: [{ - id: 'p0', - position: [0 + n.pos1(t) * 0.05, verticalPos, 0 + n.pos2(t) * 0.05], - motion_score: ms, - pose: exerciseType === 'rest' || exerciseType === 'cooldown' ? 'standing' : 'exercising', - facing: 0, - exerciseType, - exercisePhase: exerciseType === 'jumping_jacks' ? 'high_cadence' : (exerciseType === 'squats' ? 'controlled' : 'recovery'), - }], - estimated_persons: 1, - edge_modules: { - breathing_sync: { cadence: breathRate.toFixed(1), sync_quality: actualMotion > 0.5 ? 0.7 : 0.85 }, - gesture: { type: exerciseType !== 'rest' && exerciseType !== 'cooldown' ? 'exercise_rep' : 'none', count: repCount }, - vital_trend: { - status: hrRate > 170 ? 'warning' : (hrRate > 140 ? 'elevated' : 'normal'), - hr_zone: hrRate > 155 ? 'anaerobic' : (hrRate > 130 ? 'threshold' : (hrRate > 110 ? 'aerobic' : (hrRate > 90 ? 'warmup' : 'resting'))), - hr_lag_s: exerciseType === 'rest' ? 'recovering' : 'tracking', - intensity: (targetIntensity * 100).toFixed(0) + '%', - workout_phase: exerciseType, - }, - }, - }); - } - - // ======================================================================== - // 12. Security Patrol — checkpoint pauses, speed variation, anomaly buildup - // ======================================================================== - - _securityPatrol(t) { - const n = _getNoiseBank('security_patrol'); - - // Patrol route: rectangular with checkpoint pauses at corners - const patrolSpeed = 0.18; // slightly slower for realism - const rawPatrolT = (t * patrolSpeed) % 1; // 0..1 around route - - // Checkpoint pauses: guard slows/pauses at each corner (0.25, 0.5, 0.75, 1.0) - // Remap rawPatrolT to account for pauses - const cornerDuration = 0.04; // proportion of circuit spent pausing at each corner - let patrolT = rawPatrolT; - let atCheckpoint = false; - let checkpointCorner = -1; - const corners = [0, 0.25, 0.5, 0.75]; - for (let ci = 0; ci < 4; ci++) { - const c = corners[ci]; - const next = ci < 3 ? corners[ci + 1] : 1; - if (rawPatrolT >= c && rawPatrolT < c + cornerDuration) { - patrolT = c; - atCheckpoint = true; - checkpointCorner = ci; - break; - } - } - - // Speed variation: faster on long stretches, slower near corners - let px, pz, facing; - if (patrolT < 0.25) { - const p = patrolT / 0.25; - px = -3 + p * 6; pz = -2; facing = 0; - } else if (patrolT < 0.5) { - const p = (patrolT - 0.25) / 0.25; - px = 3; pz = -2 + p * 4; facing = Math.PI * 0.5; - } else if (patrolT < 0.75) { - const p = (patrolT - 0.5) / 0.25; - px = 3 - p * 6; pz = 2; facing = Math.PI; - } else { - const p = (patrolT - 0.75) / 0.25; - px = -3; pz = 2 - p * 4; facing = Math.PI * 1.5; - } - - // At checkpoint: guard looks around (facing oscillates) - if (atCheckpoint) { - facing += Math.sin(t * 3) * 0.8; // scanning left-right - } - - // Add natural movement noise - px += n.pos1(t) * 0.05; - pz += n.pos2(t) * 0.05; - - const guardSpeed = atCheckpoint ? 5 : (80 + _harmonicNoise(t, 10.1, 2) * 20); - const zone = px > 0 ? (pz > 0 ? 'NE' : 'SE') : (pz > 0 ? 'NW' : 'SW'); - - // Anomaly: starts as faint signal, builds confidence, guard responds - const anomalyCycle = t % 25; - const anomalyFaint = anomalyCycle >= 14 && anomalyCycle < 17; // first hints - const anomalyBuilding = anomalyCycle >= 17 && anomalyCycle < 19; // confidence builds - const anomalyConfirmed = anomalyCycle >= 19 && anomalyCycle < 22; // confirmed, guard responds - const anomalyActive = anomalyFaint || anomalyBuilding || anomalyConfirmed; - - let anomalyScore = 0; - if (anomalyFaint) anomalyScore = 0.15 + _smoothstep(14, 17, anomalyCycle) * 0.2; - else if (anomalyBuilding) anomalyScore = 0.35 + _smoothstep(17, 19, anomalyCycle) * 0.35; - else if (anomalyConfirmed) anomalyScore = 0.7 + _smoothstep(19, 20, anomalyCycle) * 0.15; - - // Anomaly position (opposite quadrant) - const ax = -px * 0.5 + Math.sin(t * 0.3) * 0.3; - const az = -pz * 0.4 + Math.cos(t * 0.25) * 0.2; - - // Guard changes path toward anomaly when confirmed - if (anomalyConfirmed) { - const redirectStrength = _smoothstep(19, 20, anomalyCycle); - px = _lerp(px, ax, redirectStrength * 0.4); - pz = _lerp(pz, az, redirectStrength * 0.4); - facing = Math.atan2(ax - px, az - pz); - } - - const amplitude = new Float32Array(64); - for (let i = 0; i < 64; i++) { - amplitude[i] = 0.3 + 0.15 * Math.sin(t * 1.0 + i * 0.15) - + _harmonicNoise(t, i * 0.13, 2) * 0.01; - } - - const persons = [{ - id: 'guard', - position: [px, 0, pz], - motion_score: guardSpeed, - pose: atCheckpoint ? 'standing' : (anomalyConfirmed ? 'alert' : 'walking'), - facing, - }]; - if (anomalyActive) { - persons.push({ - id: 'anomaly', - position: [ax, 0, az], - motion_score: anomalyFaint ? 8 : (anomalyBuilding ? 15 : 25), - pose: 'crouching', - facing: Math.atan2(px - ax, pz - az), - signal_confidence: anomalyScore, - }); - } - - return this._baseFrame({ - nodes: [ - { node_id: 1, rssi_dbm: -40 + Math.sin(t * 0.8) * 3 + n.rssi(t) * 0.5, position: [4, 2, -4], amplitude, subcarrier_count: 64 }, - { node_id: 2, rssi_dbm: -42 + Math.sin(t * 0.6) * 2 + n.env(t) * 0.3, position: [-4, 2, 4], amplitude: new Float32Array(amplitude), subcarrier_count: 64 }, - ], - features: { - mean_rssi: -40 + Math.sin(t * 0.8) * 3 + n.rssi(t) * 0.5, - variance: 2.5 + Math.sin(t * 0.5) * 0.8 + (anomalyActive ? anomalyScore * 2 : 0), - std: 1.58 + anomalyScore * 0.5, - motion_band_power: atCheckpoint ? 0.03 : 0.18, - breathing_band_power: 0.06, - dominant_freq_hz: atCheckpoint ? 0.1 : 0.8, - spectral_power: 0.3 + anomalyScore * 0.2, - }, - classification: { - motion_level: atCheckpoint ? 'present_still' : 'active', - presence: true, - confidence: 0.85, - anomaly_zone: anomalyActive, - anomaly_confidence: anomalyScore, - }, - signal_field: { - grid_size: [20, 1, 20], - values: anomalyActive - ? this._twoPresenceField(10 + px * 1.5, 10 + pz * 1.5, 10 + ax * 1.5, 10 + az * 1.5, t) - : this._presenceField(10 + px * 1.5, 10 + pz * 1.5, 2.5, t), - }, - vital_signs: { - breathing_rate_bpm: 16 + n.breath(t) * 0.5, - heart_rate_bpm: 78 + (anomalyConfirmed ? 12 : 0) + n.hr(t) * 1, - breathing_confidence: 0.6, - heart_rate_confidence: 0.5, - }, - persons, - estimated_persons: anomalyActive ? 2 : 1, - edge_modules: { - behavioral_profiler: { - guard_zone: zone, - coverage_pct: 72 + _harmonicNoise(t, 11.1, 2) * 3, - anomaly_score: anomalyScore, - checkpoint_active: atCheckpoint, - checkpoint_corner: checkpointCorner >= 0 ? ['SW', 'SE', 'NE', 'NW'][checkpointCorner] : null, - guard_response: anomalyConfirmed ? 'investigating' : (anomalyBuilding ? 'alerted' : 'patrolling'), - }, - perimeter_breach: { - detected: anomalyScore > 0.5, - confidence: anomalyScore > 0.5 ? anomalyScore : 0, - zone: anomalyActive ? (zone === 'NE' ? 'SW' : 'NE') : 'none', - first_detected_s: anomalyFaint ? (anomalyCycle - 14).toFixed(1) : null, - buildup_phase: anomalyFaint ? 'faint' : (anomalyBuilding ? 'building' : (anomalyConfirmed ? 'confirmed' : 'none')), - }, - }, - }); - } - - // ---- Helpers ---- - - _flatField(base) { - const vals = []; - // Spatially coherent noise: smooth gradient + gentle ripple - for (let iz = 0; iz < 20; iz++) { - for (let ix = 0; ix < 20; ix++) { - const gradient = Math.sin(ix * 0.3) * Math.sin(iz * 0.25) * 0.01; - const ripple = _harmonicNoise(ix * 0.5 + iz * 0.7, ix + iz * 20, 2) * 0.005; - vals.push(_clamp(base + gradient + ripple, 0, 1)); - } - } - return vals; - } - - _presenceField(cx, cz, radius, t) { - const vals = []; - for (let iz = 0; iz < 20; iz++) { - for (let ix = 0; ix < 20; ix++) { - const dx = ix - cx, dz = iz - cz; - const d = Math.sqrt(dx * dx + dz * dz); - // Spatially coherent noise (smooth, not random per cell) - const noise = _harmonicNoise(t * 0.5 + ix * 0.4 + iz * 0.3, ix + iz * 20, 2) * 0.015; - const v = _gaussian(d, radius) * 0.7 + noise; - vals.push(_clamp(v, 0, 1)); - } - } - return vals; - } - - _twoPresenceField(x1, z1, x2, z2, t) { - const vals = []; - for (let iz = 0; iz < 20; iz++) { - for (let ix = 0; ix < 20; ix++) { - const d1 = Math.sqrt((ix - x1) ** 2 + (iz - z1) ** 2); - const d2 = Math.sqrt((ix - x2) ** 2 + (iz - z2) ** 2); - const v1 = _gaussian(d1, 1.7) * 0.6; - const v2 = _gaussian(d2, 1.7) * 0.55; - const noise = _harmonicNoise(t * 0.5 + ix * 0.4 + iz * 0.3, ix + iz * 20, 2) * 0.012; - vals.push(_clamp(v1 + v2 + noise, 0, 1)); - } - } - return vals; - } - - /** Search & rescue field with scanning sweep and gradual target lock */ - _searchRescueField(t, scanning, detected, confidence) { - const vals = []; - const targetCx = 14, targetCz = 10; - for (let iz = 0; iz < 20; iz++) { - for (let ix = 0; ix < 20; ix++) { - let v = 0; - // Scan sweep (rotating beam) - if (scanning) { - const scanAngle = t * 0.8; - const cellAngle = Math.atan2(iz - 10, ix - 10); - const angleDiff = Math.abs(((cellAngle - scanAngle + Math.PI) % (2 * Math.PI)) - Math.PI); - v += _gaussian(angleDiff, 0.5) * 0.15; - } - // Target presence (gradually intensifying) - if (detected) { - const d = Math.sqrt((ix - targetCx) ** 2 + (iz - targetCz) ** 2); - v += _gaussian(d, 3.5 - confidence * 2) * confidence * 0.7; - } - // Background noise - v += _harmonicNoise(t * 0.3 + ix * 0.5 + iz * 0.6, ix + iz * 20, 2) * 0.01; - vals.push(_clamp(v, 0, 1)); - } - } - return vals; - } - - _generateIQ(count, scale, t) { - const iq = []; - for (let i = 0; i < count; i++) { - const phase = t * 0.5 + i * 0.2 + Math.sin(t * 0.3 + i * 0.1) * 0.5; - const amp = scale * (0.5 + 0.5 * Math.sin(t * 0.2 + i * 0.15)); - iq.push({ i: amp * Math.cos(phase), q: amp * Math.sin(phase) }); - } - return iq; - } - - _generateVariance(count, scale, t) { - const v = new Float32Array(count); - for (let i = 0; i < count; i++) v[i] = scale * (0.3 + 0.7 * Math.abs(Math.sin(t * 0.4 + i * 0.25))); - return v; - } - - _blend(a, b, alpha) { - const beta = 1 - alpha; - const result = JSON.parse(JSON.stringify(b)); - - if (a.features && b.features) { - for (const key of Object.keys(b.features)) { - if (typeof b.features[key] === 'number' && typeof a.features[key] === 'number') - result.features[key] = a.features[key] * beta + b.features[key] * alpha; - } - } - if (a.signal_field?.values && b.signal_field?.values) { - const len = Math.min(a.signal_field.values.length, b.signal_field.values.length); - for (let i = 0; i < len; i++) result.signal_field.values[i] = a.signal_field.values[i] * beta + b.signal_field.values[i] * alpha; - } - if (a.vital_signs && b.vital_signs) { - for (const key of Object.keys(b.vital_signs)) { - if (typeof b.vital_signs[key] === 'number' && typeof a.vital_signs[key] === 'number') - result.vital_signs[key] = a.vital_signs[key] * beta + b.vital_signs[key] * alpha; - } - } - if (a.nodes?.[0]?.amplitude && b.nodes?.[0]?.amplitude) { - const ampA = a.nodes[0].amplitude, ampB = b.nodes[0].amplitude; - const len = Math.min(ampA.length, ampB.length); - const blended = new Float32Array(len); - for (let i = 0; i < len; i++) blended[i] = ampA[i] * beta + ampB[i] * alpha; - result.nodes[0].amplitude = blended; - } - return result; - } -} diff --git a/observatory/js/js/holographic-panel.js b/observatory/js/js/holographic-panel.js deleted file mode 100644 index 97c586c0..00000000 --- a/observatory/js/js/holographic-panel.js +++ /dev/null @@ -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(); - } -} diff --git a/observatory/js/js/hud-controller.js b/observatory/js/js/hud-controller.js deleted file mode 100644 index 14afc6b5..00000000 --- a/observatory/js/js/hud-controller.js +++ /dev/null @@ -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 += ``; - } - 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/observatory/js/js/main.js b/observatory/js/js/main.js deleted file mode 100644 index 26abbe2c..00000000 --- a/observatory/js/js/main.js +++ /dev/null @@ -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(); diff --git a/observatory/js/js/nebula-background.js b/observatory/js/js/nebula-background.js deleted file mode 100644 index 98ad7e84..00000000 --- a/observatory/js/js/nebula-background.js +++ /dev/null @@ -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(); - } -} diff --git a/observatory/js/js/phase-constellation.js b/observatory/js/js/phase-constellation.js deleted file mode 100644 index c38444dd..00000000 --- a/observatory/js/js/phase-constellation.js +++ /dev/null @@ -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(); - } -} diff --git a/observatory/js/js/post-processing.js b/observatory/js/js/post-processing.js deleted file mode 100644 index 02186e38..00000000 --- a/observatory/js/js/post-processing.js +++ /dev/null @@ -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(); - } -} diff --git a/observatory/js/js/presence-cartography.js b/observatory/js/js/presence-cartography.js deleted file mode 100644 index 37c6e339..00000000 --- a/observatory/js/js/presence-cartography.js +++ /dev/null @@ -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(); - } -} diff --git a/observatory/js/js/subcarrier-manifold.js b/observatory/js/js/subcarrier-manifold.js deleted file mode 100644 index b410aa1a..00000000 --- a/observatory/js/js/subcarrier-manifold.js +++ /dev/null @@ -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(); - } -} diff --git a/observatory/js/js/vitals-oracle.js b/observatory/js/js/vitals-oracle.js deleted file mode 100644 index e061f800..00000000 --- a/observatory/js/js/vitals-oracle.js +++ /dev/null @@ -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(); - } -}