commit e4e6c1e60044dda303f02f66eea3549d5229e547 Author: ruv Date: Thu Mar 5 10:35:27 2026 -0500 deploy: Observatory demo for GitHub Pages Static demo of the Psychohistory Observatory visualization (ADR-047). Runs in demo mode with synthetic CSI data — cycles through 4 scenarios (empty room, breathing, walking, fall event) every 30 seconds. Live at: https://ruvnet.github.io/RuView/ Co-Authored-By: Claude Opus 4.6 diff --git a/index.html b/index.html new file mode 100644 index 00000000..6b578dbc --- /dev/null +++ b/index.html @@ -0,0 +1,340 @@ + + + + + + RuView Observatory — WiFi DensePose + + + + + + +
+ + +
+ +
WiFi DensePose Sensing Observatory
+
+ + +
+
+ + DEMO +
+
+ + +
+
+ + +
+ + +
+
Vital Signs
+
+
+
+
Heart Rate
+
-- BPM
+
+
+
+
+
+
+
Respiration
+
-- RPM
+
+
+
+
+
+
+
Confidence
+
--%
+
+
+
+
+ + +
+
WiFi Signal
+
+ RSSI + -- dBm +
+
+ Variance + -- +
+
+ Motion + -- +
+
+ Persons + 0 + +
+ + +
Presence
+
+ ABSENT +
+ +
+ + +
+ + +
+
Human Pose Estimation
+
+
Vital Sign Monitoring
+
+
Presence Detection
+
+ + +
+ [A] Orbit + [D] Scenario + [F] FPS + [S] Settings + [Space] Pause +
+
+ + + + + + + + + + diff --git a/observatory/css/observatory.css b/observatory/css/observatory.css new file mode 100644 index 00000000..e289d65f --- /dev/null +++ b/observatory/css/observatory.css @@ -0,0 +1,698 @@ +/* ============================================================ + 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/convergence-engine.js b/observatory/js/convergence-engine.js new file mode 100644 index 00000000..f9e45f84 --- /dev/null +++ b/observatory/js/convergence-engine.js @@ -0,0 +1,221 @@ +/** + * 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/demo-data.js b/observatory/js/demo-data.js new file mode 100644 index 00000000..016fb059 --- /dev/null +++ b/observatory/js/demo-data.js @@ -0,0 +1,1794 @@ +/** + * 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/figure-pool.js b/observatory/js/figure-pool.js new file mode 100644 index 00000000..b2c54925 --- /dev/null +++ b/observatory/js/figure-pool.js @@ -0,0 +1,513 @@ +/** + * FigurePool — Manages a pool of wireframe human figures for multi-person rendering. + * + * Extracted from main.js Observatory class. Owns the lifecycle of up to MAX_FIGURES + * Three.js figure groups, each containing joints, bones, body segments, and aura. + * + * Improvements over the original inline implementation: + * - Smooth joint interpolation (lerp toward target instead of snapping) + * - Joint pulsation synced with breathing + * - Natural bone thickness taper (thicker at shoulder/hip, thinner at extremities) + * - Secondary motion with slight delay/overshoot for organic feel + * - Pose-adaptive aura shape (wider for exercise, narrower for crouching) + */ +import * as THREE from 'three'; + +// 17-keypoint COCO skeleton connectivity +export const SKELETON_PAIRS = [ + [0, 1], [0, 2], [1, 3], [2, 4], + [5, 6], [5, 7], [7, 9], [6, 8], [8, 10], + [5, 11], [6, 12], [11, 12], + [11, 13], [13, 15], [12, 14], [14, 16], +]; + +// Body segment cylinders that give volume to the wireframe +export const BODY_SEGMENT_DEFS = [ + { joints: [5, 11], radius: 0.12 }, // left torso + { joints: [6, 12], radius: 0.12 }, // right torso + { joints: [5, 6], radius: 0.1 }, // shoulder bar + { joints: [11, 12], radius: 0.1 }, // hip bar + { joints: [5, 7], radius: 0.05 }, // left upper arm + { joints: [6, 8], radius: 0.05 }, // right upper arm + { joints: [7, 9], radius: 0.04 }, // left forearm + { joints: [8, 10], radius: 0.04 }, // right forearm + { joints: [11, 13], radius: 0.07 }, // left thigh + { joints: [12, 14], radius: 0.07 }, // right thigh + { joints: [13, 15], radius: 0.05 }, // left shin + { joints: [14, 16], radius: 0.05 }, // right shin + { joints: [0, 0], radius: 0.1, isHead: true }, +]; + +// Bone thickness multipliers — thicker at torso, thinner at extremities +const BONE_TAPER = (() => { + const tapers = new Map(); + // Torso and shoulder/hip connections are thickest + tapers.set('5-6', 1.4); // shoulder bar + tapers.set('11-12', 1.3); // hip bar + tapers.set('5-11', 1.3); // left torso + tapers.set('6-12', 1.3); // right torso + // Upper limbs + tapers.set('5-7', 1.0); // left upper arm + tapers.set('6-8', 1.0); // right upper arm + tapers.set('11-13', 1.1); // left thigh + tapers.set('12-14', 1.1); // right thigh + // Lower limbs / extremities — thinnest + tapers.set('7-9', 0.7); // left forearm + tapers.set('8-10', 0.7); // right forearm + tapers.set('13-15', 0.8); // left shin + tapers.set('14-16', 0.8); // right shin + // Head connections + tapers.set('0-1', 0.5); + tapers.set('0-2', 0.5); + tapers.set('1-3', 0.4); + tapers.set('2-4', 0.4); + return tapers; +})(); + +// Secondary motion delay factors per joint — extremities lag more +const SECONDARY_DELAY = [ + 0.12, // 0 nose + 0.10, // 1 left eye + 0.10, // 2 right eye + 0.08, // 3 left ear + 0.08, // 4 right ear + 0.18, // 5 left shoulder + 0.18, // 6 right shoulder + 0.14, // 7 left elbow + 0.14, // 8 right elbow + 0.10, // 9 left wrist (most lag) + 0.10, // 10 right wrist + 0.20, // 11 left hip (anchored, fast follow) + 0.20, // 12 right hip + 0.15, // 13 left knee + 0.15, // 14 right knee + 0.10, // 15 left ankle + 0.10, // 16 right ankle +]; + +// Overshoot factors — extremities overshoot more for organic feel +const OVERSHOOT = [ + 0.02, // 0 nose + 0.01, // 1 left eye + 0.01, // 2 right eye + 0.01, // 3 left ear + 0.01, // 4 right ear + 0.03, // 5 left shoulder + 0.03, // 6 right shoulder + 0.05, // 7 left elbow + 0.05, // 8 right elbow + 0.08, // 9 left wrist + 0.08, // 10 right wrist + 0.02, // 11 left hip + 0.02, // 12 right hip + 0.04, // 13 left knee + 0.04, // 14 right knee + 0.06, // 15 left ankle + 0.06, // 16 right ankle +]; + +const MAX_FIGURES = 4; + +// Reusable vectors to avoid per-frame allocation +const _vecFrom = new THREE.Vector3(); +const _vecTo = new THREE.Vector3(); +const _vecTarget = new THREE.Vector3(); + +export class FigurePool { + /** + * @param {THREE.Scene} scene - The Three.js scene to add figures to + * @param {object} settings - Shared settings object (boneThick, jointSize, glow, etc.) + * @param {object} poseSystem - PoseSystem instance with generateKeypoints(person, elapsed, breathPulse) + */ + constructor(scene, settings, poseSystem) { + this._scene = scene; + this._settings = settings; + this._poseSystem = poseSystem; + this._figures = []; + this._maxFigures = MAX_FIGURES; + this._build(); + } + + /** @returns {Array} The array of figure objects */ + get figures() { return this._figures; } + + // ---- Construction ---- + + _build() { + for (let f = 0; f < this._maxFigures; f++) { + this._figures.push(this._createFigure()); + } + } + + _createFigure() { + const group = new THREE.Group(); + this._scene.add(group); + const wireColor = new THREE.Color(this._settings.wireColor); + const jointColor = new THREE.Color(this._settings.jointColor); + + // Joints (17 COCO keypoints) + const joints = []; + for (let i = 0; i < 17; i++) { + const isNose = i === 0; + const size = isNose ? this._settings.jointSize * 0.7 : this._settings.jointSize; + const geo = new THREE.SphereGeometry(size, 12, 12); + const mat = new THREE.MeshStandardMaterial({ + color: isNose ? wireColor : jointColor, + emissive: isNose ? wireColor : jointColor, + emissiveIntensity: 0.35, + transparent: true, opacity: 0, + roughness: 0.3, metalness: 0.2, + }); + const sphere = new THREE.Mesh(geo, mat); + sphere.castShadow = true; + group.add(sphere); + joints.push(sphere); + + // Halo glow on key joints + if ([5, 6, 9, 10, 11, 12, 15, 16].includes(i)) { + const haloGeo = new THREE.SphereGeometry(size * 1.3, 8, 8); + const haloMat = new THREE.MeshBasicMaterial({ + color: jointColor, + transparent: true, opacity: 0, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const halo = new THREE.Mesh(haloGeo, haloMat); + sphere.add(halo); + sphere._halo = halo; + sphere._haloMat = haloMat; + + const glow = new THREE.PointLight(jointColor, 0, 0.8); + sphere.add(glow); + sphere._glow = glow; + } + } + + // Bones — tapered thickness + const bones = []; + for (const [a, b] of SKELETON_PAIRS) { + const taperKey = `${Math.min(a, b)}-${Math.max(a, b)}`; + const taper = BONE_TAPER.get(taperKey) || 1.0; + const thick = this._settings.boneThick * taper; + // Top radius thicker than bottom for natural taper along bone length + const topRadius = thick; + const botRadius = thick * 0.65; + const geo = new THREE.CylinderGeometry(topRadius, botRadius, 1, 8, 1); + geo.translate(0, 0.5, 0); + geo.rotateX(Math.PI / 2); + const mat = new THREE.MeshStandardMaterial({ + color: wireColor, emissive: wireColor, emissiveIntensity: 0.3, + transparent: true, opacity: 0, roughness: 0.4, metalness: 0.1, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.castShadow = true; + group.add(mesh); + bones.push({ mesh, a, b, taper }); + } + + // Body segments (volume cylinders and head sphere) + const bodySegments = []; + for (const seg of BODY_SEGMENT_DEFS) { + const geo = seg.isHead + ? new THREE.SphereGeometry(seg.radius, 12, 12) + : new THREE.CylinderGeometry(seg.radius, seg.radius * 0.85, 1, 8, 1); + if (!seg.isHead) { + geo.translate(0, 0.5, 0); + geo.rotateX(Math.PI / 2); + } + const mat = new THREE.MeshStandardMaterial({ + color: wireColor, emissive: wireColor, emissiveIntensity: 0.12, + transparent: true, opacity: 0, roughness: 0.5, metalness: 0.1, + side: THREE.DoubleSide, + }); + const mesh = new THREE.Mesh(geo, mat); + group.add(mesh); + bodySegments.push({ mesh, mat, a: seg.joints[0], b: seg.joints[1], isHead: seg.isHead }); + } + + // Aura cylinder + const auraGeo = new THREE.CylinderGeometry(0.4, 0.3, 1.7, 16, 1, true); + const auraMat = new THREE.MeshBasicMaterial({ + color: wireColor, transparent: true, opacity: 0, + side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false, + }); + const aura = new THREE.Mesh(auraGeo, auraMat); + aura.position.y = 1; + group.add(aura); + + // Per-figure point light + const personLight = new THREE.PointLight(wireColor, 0, 6); + personLight.position.y = 1; + group.add(personLight); + + // Interpolation state: previous positions for smooth lerp and secondary motion + const prevPositions = []; + const velocities = []; + for (let i = 0; i < 17; i++) { + prevPositions.push(new THREE.Vector3(0, 0, 0)); + velocities.push(new THREE.Vector3(0, 0, 0)); + } + + return { + group, joints, bones, bodySegments, aura, auraMat, personLight, + visible: false, + prevPositions, + velocities, + _initialized: false, + _lastPose: null, + }; + } + + // ---- Per-frame update ---- + + /** + * Update all figures based on current data frame. + * @param {object} data - Current sensing data with persons[], vital_signs, classification + * @param {number} elapsed - Elapsed time in seconds + */ + update(data, elapsed) { + const persons = data?.persons || []; + const vs = data?.vital_signs || {}; + const isPresent = data?.classification?.presence || false; + const breathBpm = vs.breathing_rate_bpm || 0; + const breathPulse = breathBpm > 0 + ? Math.sin(elapsed * Math.PI * 2 * (breathBpm / 60)) * 0.012 + : 0; + + for (let f = 0; f < this._figures.length; f++) { + const fig = this._figures[f]; + if (f < persons.length && isPresent) { + const p = persons[f]; + const kps = this._poseSystem.generateKeypoints(p, elapsed, breathPulse); + this.applyKeypoints(fig, kps, breathPulse, p.position || [0, 0, 0], elapsed, p.pose); + fig.visible = true; + } else { + if (fig.visible) { + this.hide(fig); + fig.visible = false; + } + } + } + } + + /** + * Apply keypoints to a figure with smooth interpolation, pulsation, and secondary motion. + * @param {object} fig - Figure object from the pool + * @param {Array} kps - 17-element array of [x,y,z] keypoint positions + * @param {number} breathPulse - Current breathing pulse value + * @param {Array} pos - Person world position [x,y,z] + * @param {number} elapsed - Elapsed time for pulsation effects + * @param {string} pose - Current pose name for aura adaptation + */ + applyKeypoints(fig, kps, breathPulse, pos, elapsed = 0, pose = 'standing') { + const lerpFactor = fig._initialized ? 0.18 : 1.0; + + // Joints with smooth interpolation and secondary motion + for (let i = 0; i < 17 && i < kps.length; i++) { + const j = fig.joints[i]; + _vecTarget.set(kps[i][0], kps[i][1], kps[i][2]); + + if (fig._initialized) { + // Compute velocity for overshoot + const prev = fig.prevPositions[i]; + const vel = fig.velocities[i]; + + // Smooth lerp with per-joint delay + const delay = SECONDARY_DELAY[i]; + const jointLerp = lerpFactor + delay; + j.position.lerp(_vecTarget, Math.min(jointLerp, 0.95)); + + // Apply subtle overshoot based on velocity change + const overshoot = OVERSHOOT[i]; + vel.subVectors(j.position, prev).multiplyScalar(overshoot); + j.position.add(vel); + + prev.copy(j.position); + } else { + // First frame: snap to position + j.position.copy(_vecTarget); + fig.prevPositions[i].copy(_vecTarget); + fig.velocities[i].set(0, 0, 0); + } + + j.material.opacity = 0.95; + + // Joint pulsation synced with breathing + const pulseFactor = 1.0 + Math.abs(breathPulse) * 8.0; + j.material.emissiveIntensity = 0.35 * pulseFactor; + + const baseScale = this._settings.jointSize / 0.04; + // Subtle size pulsation on breathing + const pulseScale = baseScale * (1.0 + Math.abs(breathPulse) * 3.0); + j.scale.setScalar(pulseScale); + + if (j._haloMat) { + j._haloMat.opacity = 0.04 * this._settings.glow * pulseFactor; + } + if (j._glow) { + j._glow.intensity = this._settings.glow * 0.12 * pulseFactor; + } + } + + fig._initialized = true; + + // Bones with tapered thickness + for (const bone of fig.bones) { + const pA = kps[bone.a], pB = kps[bone.b]; + if (pA && pB) { + _vecFrom.set(pA[0], pA[1], pA[2]); + _vecTo.set(pB[0], pB[1], pB[2]); + const len = _vecFrom.distanceTo(_vecTo); + + // Use interpolated joint positions for smooth bone movement + if (fig._initialized) { + const jA = fig.joints[bone.a]; + const jB = fig.joints[bone.b]; + bone.mesh.position.copy(jA.position); + bone.mesh.scale.set(1, 1, jA.position.distanceTo(jB.position)); + bone.mesh.lookAt(jB.position); + } else { + bone.mesh.position.copy(_vecFrom); + bone.mesh.scale.set(1, 1, len); + bone.mesh.lookAt(_vecTo); + } + + bone.mesh.material.opacity = 0.85; + bone.mesh.material.emissiveIntensity = 0.3 + Math.abs(breathPulse) * 2.0; + } + } + + // Body segments + for (const seg of fig.bodySegments) { + if (seg.isHead) { + const headJoint = fig.joints[seg.a]; + seg.mesh.position.set(headJoint.position.x, headJoint.position.y + 0.05, headJoint.position.z); + seg.mat.opacity = 0.15; + } else { + const jA = fig.joints[seg.a]; + const jB = fig.joints[seg.b]; + if (jA && jB) { + const len = jA.position.distanceTo(jB.position); + seg.mesh.position.copy(jA.position); + seg.mesh.scale.set(1, 1, len); + seg.mesh.lookAt(jB.position); + seg.mat.opacity = 0.12; + } + } + seg.mat.emissiveIntensity = 0.1 + Math.abs(breathPulse) * 0.4; + } + + // Aura — adapt shape to pose + const hipY = (fig.joints[11].position.y + fig.joints[12].position.y) / 2; + const cx = (fig.joints[11].position.x + fig.joints[12].position.x) / 2; + const cz = (fig.joints[11].position.z + fig.joints[12].position.z) / 2; + fig.aura.position.set(cx, hipY, cz); + fig.auraMat.opacity = this._settings.aura + Math.abs(breathPulse) * 0.8; + + // Pose-adaptive aura: compute from actual keypoint spread + const auraShape = this._computeAuraShape(fig, pose, breathPulse); + fig.aura.scale.set(auraShape.scaleX, auraShape.scaleY, auraShape.scaleZ); + + // Person light + fig.personLight.position.set(pos[0], 1.2, pos[2]); + fig.personLight.intensity = this._settings.glow * 0.4; + + fig._lastPose = pose; + } + + /** + * Compute pose-adaptive aura shape based on actual keypoint spread. + * Wider for exercise/spread poses, narrower for crouching/compact poses. + */ + _computeAuraShape(fig, pose, breathPulse) { + // Measure horizontal spread from shoulders and hips + const lShoulder = fig.joints[5].position; + const rShoulder = fig.joints[6].position; + const lHip = fig.joints[11].position; + const rHip = fig.joints[12].position; + const nose = fig.joints[0].position; + const lAnkle = fig.joints[15].position; + const rAnkle = fig.joints[16].position; + + // Horizontal spread (X-Z plane) + const shoulderWidth = Math.sqrt( + (rShoulder.x - lShoulder.x) ** 2 + + (rShoulder.z - lShoulder.z) ** 2 + ); + const ankleWidth = Math.sqrt( + (rAnkle.x - lAnkle.x) ** 2 + + (rAnkle.z - lAnkle.z) ** 2 + ); + const maxWidth = Math.max(shoulderWidth, ankleWidth); + + // Vertical extent + const headY = nose.y; + const footY = Math.min(lAnkle.y, rAnkle.y); + const height = headY - footY; + + // Normalize to base aura dimensions + const baseWidth = 0.44; // default shoulder width + const baseHeight = 1.7; // default standing height + + const widthRatio = Math.max(0.6, Math.min(2.0, maxWidth / baseWidth)); + const heightRatio = Math.max(0.4, Math.min(1.3, height / baseHeight)); + + // Breathing modulation + const breathMod = 1 + breathPulse * 2; + + return { + scaleX: widthRatio * breathMod, + scaleY: heightRatio * breathMod, + scaleZ: widthRatio * breathMod, + }; + } + + /** + * Hide a figure by fading all materials to invisible. + * @param {object} fig - Figure object to hide + */ + hide(fig) { + for (const j of fig.joints) { + j.material.opacity = 0; + if (j._haloMat) j._haloMat.opacity = 0; + if (j._glow) j._glow.intensity = 0; + } + for (const b of fig.bones) b.mesh.material.opacity = 0; + for (const seg of fig.bodySegments) seg.mat.opacity = 0; + fig.auraMat.opacity = 0; + fig.personLight.intensity = 0; + fig._initialized = false; + } + + /** + * Apply wire and joint colors to all figures in the pool. + * @param {THREE.Color} wireColor + * @param {THREE.Color} jointColor + */ + applyColors(wireColor, jointColor) { + for (const fig of this._figures) { + for (let i = 0; i < fig.joints.length; i++) { + const j = fig.joints[i]; + if (i === 0) { + j.material.color.copy(wireColor); + j.material.emissive.copy(wireColor); + } else { + j.material.color.copy(jointColor); + j.material.emissive.copy(jointColor); + } + if (j._haloMat) j._haloMat.color.copy(jointColor); + if (j._glow) j._glow.color.copy(jointColor); + } + for (const b of fig.bones) { + b.mesh.material.color.copy(wireColor); + b.mesh.material.emissive.copy(wireColor); + } + for (const seg of fig.bodySegments) { + seg.mat.color.copy(wireColor); + seg.mat.emissive.copy(wireColor); + } + fig.auraMat.color.copy(wireColor); + fig.personLight.color.copy(wireColor); + } + } +} diff --git a/observatory/js/holographic-panel.js b/observatory/js/holographic-panel.js new file mode 100644 index 00000000..97c586c0 --- /dev/null +++ b/observatory/js/holographic-panel.js @@ -0,0 +1,121 @@ +/** + * 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/hud-controller.js b/observatory/js/hud-controller.js new file mode 100644 index 00000000..ddc9fdf2 --- /dev/null +++ b/observatory/js/hud-controller.js @@ -0,0 +1,567 @@ +/** + * HudController — Extracted HUD update, settings dialog, and scenario UI + * + * Manages all DOM-based HUD elements: + * - Vital sign display with smooth lerp transitions and color coding + * - Signal metrics, sparkline, and presence indicator + * - Scenario description and edge module badges + * - Mini person-count dot visualization + * - Settings dialog (tabs, ranges, presets, data source) + * - Quick-select scenario dropdown + */ + +// ---- Constants ---- + +export const SCENARIO_NAMES = [ + 'EMPTY ROOM','VITAL SIGNS','MULTI-PERSON','FALL DETECT', + 'SLEEP MONITOR','INTRUSION','GESTURE CTRL','CROWD OCCUPANCY', + 'SEARCH RESCUE','ELDERLY CARE','FITNESS','SECURITY PATROL', +]; + +export const DEFAULTS = { + bloom: 0.2, bloomRadius: 0.25, bloomThresh: 0.5, + exposure: 1.3, vignette: 0.25, grain: 0.01, chromatic: 0.0005, + boneThick: 0.018, jointSize: 0.035, glow: 0.3, trail: 0.35, + wireColor: '#00d878', jointColor: '#ff4060', aura: 0.02, + field: 0.45, waves: 0.4, ambient: 0.7, reflect: 0.2, + fov: 50, orbitSpeed: 0.15, grid: true, room: true, + scenario: 'auto', cycle: 30, dataSource: 'demo', wsUrl: '', +}; + +export const SETTINGS_VERSION = '4'; + +export const PRESETS = { + foundation: {}, + cinematic: { + bloom: 1.2, bloomRadius: 0.5, bloomThresh: 0.2, + exposure: 0.8, vignette: 0.7, grain: 0.04, chromatic: 0.002, + glow: 0.6, trail: 0.8, aura: 0.06, field: 0.4, + waves: 0.7, ambient: 0.25, reflect: 0.5, fov: 40, orbitSpeed: 0.08, + }, + minimal: { + bloom: 0.3, bloomRadius: 0.2, bloomThresh: 0.5, + exposure: 1.1, vignette: 0.2, grain: 0, chromatic: 0, + glow: 0.3, trail: 0.2, aura: 0.02, field: 0.7, + waves: 0.3, ambient: 0.6, reflect: 0.1, wireColor: '#40ff90', jointColor: '#4080ff', + }, + neon: { + bloom: 2.5, bloomRadius: 0.8, bloomThresh: 0.1, + exposure: 0.6, vignette: 0.6, grain: 0.02, chromatic: 0.004, + glow: 2.0, trail: 1.0, aura: 0.15, field: 0.6, + waves: 1.0, ambient: 0.15, reflect: 0.7, wireColor: '#00ffaa', jointColor: '#ff00ff', + }, + tactical: { + bloom: 0.5, bloomRadius: 0.3, bloomThresh: 0.4, + exposure: 0.85, vignette: 0.4, grain: 0.04, chromatic: 0.001, + glow: 0.5, trail: 0.4, aura: 0.03, field: 0.8, + waves: 0.4, ambient: 0.3, reflect: 0.15, wireColor: '#30ff60', jointColor: '#ff8800', + }, + medical: { + bloom: 0.6, bloomRadius: 0.4, bloomThresh: 0.35, + exposure: 1.0, vignette: 0.3, grain: 0.01, chromatic: 0.0005, + glow: 0.6, trail: 0.3, aura: 0.04, field: 0.5, + waves: 0.3, ambient: 0.5, reflect: 0.2, wireColor: '#00ccff', jointColor: '#ff3355', + }, +}; + +// Scenario descriptions shown below the dropdown +const SCENARIO_DESCRIPTIONS = { + auto: 'Auto-cycling through all sensing scenarios.', + empty_room: 'Baseline calibration with no human presence in the monitored zone.', + single_breathing: 'Detecting vital signs through WiFi signal micro-variations.', + two_walking: 'Tracking multiple people simultaneously via CSI multiplex separation.', + fall_event: 'Sudden posture-change detection using acceleration feature analysis.', + sleep_monitoring: 'Monitoring breathing patterns and apnea events during sleep.', + intrusion_detect: 'Passive perimeter monitoring -- no cameras, pure RF sensing.', + gesture_control: 'DTW-based gesture recognition from hand/arm motion signatures.', + crowd_occupancy: 'Estimating room occupancy count from aggregate CSI variance.', + search_rescue: 'Through-wall survivor detection using WiFi-MAT multistatic mode.', + elderly_care: 'Continuous gait analysis for early mobility-decline detection.', + fitness_tracking: 'Rep counting and exercise classification from body kinematics.', + security_patrol: 'Multi-zone presence patrol with camera-free motion heatmaps.', +}; + +// Edge modules active per scenario +const SCENARIO_EDGE_MODULES = { + auto: [], + empty_room: [], + single_breathing: ['VITALS'], + two_walking: ['GAIT', 'TRACKING'], + fall_event: ['FALL', 'VITALS'], + sleep_monitoring: ['VITALS', 'APNEA'], + intrusion_detect: ['PRESENCE', 'ALERT'], + gesture_control: ['GESTURE', 'DTW'], + crowd_occupancy: ['OCCUPANCY'], + search_rescue: ['MAT', 'VITALS', 'PRESENCE'], + elderly_care: ['GAIT', 'VITALS', 'FALL'], + fitness_tracking: ['GESTURE', 'GAIT'], + security_patrol: ['PRESENCE', 'ALERT', 'TRACKING'], +}; + +// Edge-module badge colors +const MODULE_COLORS = { + VITALS: 'var(--red-heart)', + GAIT: 'var(--green-glow)', + FALL: 'var(--red-alert)', + GESTURE: 'var(--amber)', + PRESENCE: 'var(--blue-signal)', + TRACKING: 'var(--green-bright)', + OCCUPANCY: 'var(--amber)', + ALERT: 'var(--red-alert)', + DTW: 'var(--amber)', + APNEA: 'var(--red-heart)', + MAT: 'var(--blue-signal)', +}; + +// Vital-sign color-coding thresholds +function vitalColor(type, value) { + if (value <= 0) return 'var(--text-secondary)'; + if (type === 'hr') { + if (value < 50 || value > 130) return 'var(--red-alert)'; + if (value < 60 || value > 100) return 'var(--amber)'; + return 'var(--green-glow)'; + } + if (type === 'br') { + if (value < 8 || value > 28) return 'var(--red-alert)'; + if (value < 12 || value > 20) return 'var(--amber)'; + return 'var(--green-glow)'; + } + if (type === 'conf') { + if (value < 40) return 'var(--red-alert)'; + if (value < 70) return 'var(--amber)'; + return 'var(--green-glow)'; + } + return 'var(--text-primary)'; +} + +function lerp(a, b, t) { + return a + (b - a) * t; +} + +// ---- HudController class ---- + +export class HudController { + constructor(observatory) { + this._obs = observatory; + this._settingsOpen = false; + this._rssiHistory = []; + this._sparklineCtx = document.getElementById('rssi-sparkline')?.getContext('2d'); + + // Lerp state for smooth vital-sign transitions + this._lerpHr = 0; + this._lerpBr = 0; + this._lerpConf = 0; + + // Track current scenario for description/edge updates + this._currentScenarioKey = null; + } + + // ============================================================ + // Settings dialog + // ============================================================ + + initSettings() { + const overlay = document.getElementById('settings-overlay'); + const btn = document.getElementById('settings-btn'); + const closeBtn = document.getElementById('settings-close'); + btn.addEventListener('click', () => this.toggleSettings()); + closeBtn.addEventListener('click', () => this.toggleSettings()); + overlay.addEventListener('click', (e) => { if (e.target === overlay) this.toggleSettings(); }); + + // Tab switching + document.querySelectorAll('.stab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.stab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.stab-content').forEach(c => c.classList.remove('active')); + tab.classList.add('active'); + document.getElementById(`stab-${tab.dataset.stab}`).classList.add('active'); + }); + }); + + const obs = this._obs; + const s = obs.settings; + + // Bind ranges + this._bindRange('opt-bloom', 'bloom', v => { obs._postProcessing._bloomPass.strength = v; }); + this._bindRange('opt-bloom-radius', 'bloomRadius', v => { obs._postProcessing._bloomPass.radius = v; }); + this._bindRange('opt-bloom-thresh', 'bloomThresh', v => { obs._postProcessing._bloomPass.threshold = v; }); + this._bindRange('opt-exposure', 'exposure', v => { obs._renderer.toneMappingExposure = v; }); + this._bindRange('opt-vignette', 'vignette', v => { obs._postProcessing._vignettePass.uniforms.uVignetteStrength.value = v; }); + this._bindRange('opt-grain', 'grain', v => { obs._postProcessing._vignettePass.uniforms.uGrainStrength.value = v; }); + this._bindRange('opt-chromatic', 'chromatic', v => { obs._postProcessing._vignettePass.uniforms.uChromaticStrength.value = v; }); + this._bindRange('opt-bone-thick', 'boneThick'); + this._bindRange('opt-joint-size', 'jointSize'); + this._bindRange('opt-glow', 'glow'); + this._bindRange('opt-trail', 'trail'); + this._bindRange('opt-aura', 'aura'); + this._bindRange('opt-field', 'field', v => { obs._fieldMat.opacity = v; }); + this._bindRange('opt-waves', 'waves'); + this._bindRange('opt-ambient', 'ambient', v => { obs._ambient.intensity = v; }); + this._bindRange('opt-reflect', 'reflect', v => { + obs._floorMat.roughness = 1.0 - v * 0.7; + obs._floorMat.metalness = v * 0.5; + }); + this._bindRange('opt-fov', 'fov', v => { + obs._camera.fov = v; + obs._camera.updateProjectionMatrix(); + }); + this._bindRange('opt-orbit-speed', 'orbitSpeed'); + this._bindRange('opt-cycle', 'cycle', v => { obs._demoData.setCycleDuration(v); }); + + // Color pickers + document.getElementById('opt-wire-color').value = s.wireColor; + document.getElementById('opt-wire-color').addEventListener('input', (e) => { + s.wireColor = e.target.value; obs._applyColors(); this.saveSettings(); + }); + document.getElementById('opt-joint-color').value = s.jointColor; + document.getElementById('opt-joint-color').addEventListener('input', (e) => { + s.jointColor = e.target.value; obs._applyColors(); this.saveSettings(); + }); + + // Checkboxes + document.getElementById('opt-grid').checked = s.grid; + document.getElementById('opt-grid').addEventListener('change', (e) => { + s.grid = e.target.checked; obs._grid.visible = e.target.checked; this.saveSettings(); + }); + document.getElementById('opt-room').checked = s.room; + document.getElementById('opt-room').addEventListener('change', (e) => { + s.room = e.target.checked; obs._roomWire.visible = e.target.checked; this.saveSettings(); + }); + + // Scenario select + const scenarioSel = document.getElementById('opt-scenario'); + scenarioSel.value = s.scenario; + scenarioSel.addEventListener('change', (e) => { + s.scenario = e.target.value; + obs._demoData.setScenario(e.target.value); + this.saveSettings(); + }); + + // Data source + const dsSel = document.getElementById('opt-data-source'); + dsSel.value = s.dataSource; + dsSel.addEventListener('change', (e) => { + s.dataSource = e.target.value; + document.getElementById('ws-url-row').style.display = e.target.value === 'ws' ? 'flex' : 'none'; + if (e.target.value === 'ws' && s.wsUrl) obs._connectWS(s.wsUrl); + else obs._disconnectWS(); + this.updateSourceBadge(s.dataSource, obs._ws); + this.saveSettings(); + }); + document.getElementById('ws-url-row').style.display = s.dataSource === 'ws' ? 'flex' : 'none'; + + const wsInput = document.getElementById('opt-ws-url'); + wsInput.value = s.wsUrl; + wsInput.addEventListener('change', (e) => { + s.wsUrl = e.target.value; + if (s.dataSource === 'ws') obs._connectWS(e.target.value); + this.saveSettings(); + }); + + // Buttons + document.getElementById('btn-reset-camera').addEventListener('click', () => { + obs._camera.position.set(6, 5, 8); + obs._controls.target.set(0, 1.2, 0); + obs._controls.update(); + }); + document.getElementById('btn-export-settings').addEventListener('click', () => { + const blob = new Blob([JSON.stringify(s, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'ruview-observatory-settings.json'; + a.click(); + }); + document.getElementById('btn-reset-settings').addEventListener('click', () => { + this.applyPreset(DEFAULTS); + }); + + const presetSel = document.getElementById('opt-preset'); + presetSel.addEventListener('change', (e) => { + const p = PRESETS[e.target.value]; + if (p) this.applyPreset({ ...DEFAULTS, ...p }); + }); + + obs._grid.visible = s.grid; + obs._roomWire.visible = s.room; + } + + // ============================================================ + // Quick-select (top bar scenario dropdown) + // ============================================================ + + initQuickSelect() { + const sel = document.getElementById('scenario-quick-select'); + if (!sel) return; + sel.addEventListener('change', (e) => { + this._obs._demoData.setScenario(e.target.value); + const settingsSel = document.getElementById('opt-scenario'); + if (settingsSel) settingsSel.value = e.target.value; + this._obs.settings.scenario = e.target.value; + this.saveSettings(); + }); + } + + // ============================================================ + // Toggle / save / preset + // ============================================================ + + toggleSettings() { + this._settingsOpen = !this._settingsOpen; + document.getElementById('settings-overlay').style.display = this._settingsOpen ? 'flex' : 'none'; + } + + get settingsOpen() { + return this._settingsOpen; + } + + saveSettings() { + try { + localStorage.setItem('ruview-observatory-settings', JSON.stringify(this._obs.settings)); + } catch {} + } + + applyPreset(preset) { + const obs = this._obs; + Object.assign(obs.settings, preset); + this.saveSettings(); + const rangeMap = { + 'opt-bloom': 'bloom', 'opt-bloom-radius': 'bloomRadius', 'opt-bloom-thresh': 'bloomThresh', + 'opt-exposure': 'exposure', 'opt-vignette': 'vignette', 'opt-grain': 'grain', 'opt-chromatic': 'chromatic', + 'opt-bone-thick': 'boneThick', 'opt-joint-size': 'jointSize', 'opt-glow': 'glow', 'opt-trail': 'trail', 'opt-aura': 'aura', + 'opt-field': 'field', 'opt-waves': 'waves', 'opt-ambient': 'ambient', 'opt-reflect': 'reflect', + 'opt-fov': 'fov', 'opt-orbit-speed': 'orbitSpeed', 'opt-cycle': 'cycle', + }; + for (const [id, key] of Object.entries(rangeMap)) { + const el = document.getElementById(id); + const valEl = document.getElementById(`${id}-val`); + if (el) el.value = obs.settings[key]; + if (valEl) valEl.textContent = obs.settings[key]; + } + const gridEl = document.getElementById('opt-grid'); + if (gridEl) { gridEl.checked = obs.settings.grid; obs._grid.visible = obs.settings.grid; } + const roomEl = document.getElementById('opt-room'); + if (roomEl) { roomEl.checked = obs.settings.room; obs._roomWire.visible = obs.settings.room; } + document.getElementById('opt-wire-color').value = obs.settings.wireColor; + document.getElementById('opt-joint-color').value = obs.settings.jointColor; + obs._applyPostSettings(); + obs._renderer.toneMappingExposure = obs.settings.exposure; + obs._fieldMat.opacity = obs.settings.field; + obs._ambient.intensity = obs.settings.ambient; + obs._floorMat.roughness = 1.0 - obs.settings.reflect * 0.7; + obs._floorMat.metalness = obs.settings.reflect * 0.5; + obs._camera.fov = obs.settings.fov; + obs._camera.updateProjectionMatrix(); + obs._demoData.setCycleDuration(obs.settings.cycle); + obs._applyColors(); + } + + // ============================================================ + // Source badge + // ============================================================ + + updateSourceBadge(dataSource, ws) { + const dot = document.querySelector('#data-source-badge .dot'); + const label = document.getElementById('data-source-label'); + if (dataSource === 'ws' && ws?.readyState === WebSocket.OPEN) { + dot.className = 'dot dot--live'; label.textContent = 'LIVE'; + } else { + dot.className = 'dot dot--demo'; label.textContent = 'DEMO'; + } + } + + // ============================================================ + // HUD update (called every frame) + // ============================================================ + + updateHUD(data, demoData) { + if (!data) return; + const vs = data.vital_signs || {}; + const feat = data.features || {}; + const cls = data.classification || {}; + + // Sync scenario dropdown + const quickSel = document.getElementById('scenario-quick-select'); + const cur = demoData._autoMode ? 'auto' : demoData.currentScenario; + if (quickSel && quickSel.value !== cur) quickSel.value = cur; + const autoIcon = document.getElementById('autoplay-icon'); + if (autoIcon) autoIcon.className = demoData._autoMode ? '' : 'hidden'; + + const targetHr = vs.heart_rate_bpm || 0; + const targetBr = vs.breathing_rate_bpm || 0; + const targetConf = Math.round((cls.confidence || 0) * 100); + + // Smooth lerp transitions (blend 4% per frame toward target — very stable) + const lerpFactor = 0.04; + this._lerpHr = targetHr > 0 ? lerp(this._lerpHr, targetHr, lerpFactor) : 0; + this._lerpBr = targetBr > 0 ? lerp(this._lerpBr, targetBr, lerpFactor) : 0; + this._lerpConf = targetConf > 0 ? lerp(this._lerpConf, targetConf, lerpFactor) : 0; + + const dispHr = this._lerpHr > 1 ? Math.round(this._lerpHr) : '--'; + const dispBr = this._lerpBr > 1 ? Math.round(this._lerpBr) : '--'; + const dispConf = this._lerpConf > 1 ? Math.round(this._lerpConf) : '--'; + + this._setText('hr-value', dispHr); + this._setText('br-value', dispBr); + this._setText('conf-value', dispConf); + this._setWidth('hr-bar', Math.min(100, this._lerpHr / 120 * 100)); + this._setWidth('br-bar', Math.min(100, this._lerpBr / 30 * 100)); + this._setWidth('conf-bar', this._lerpConf); + + // Color-code vital values + this._setColor('hr-value', vitalColor('hr', this._lerpHr)); + this._setColor('br-value', vitalColor('br', this._lerpBr)); + this._setColor('conf-value', vitalColor('conf', this._lerpConf)); + + // Color-code bar fills to match + this._setBarColor('hr-bar', vitalColor('hr', this._lerpHr)); + this._setBarColor('br-bar', vitalColor('br', this._lerpBr)); + this._setBarColor('conf-bar', vitalColor('conf', this._lerpConf)); + + this._setText('rssi-value', `${Math.round(feat.mean_rssi || 0)} dBm`); + this._setText('var-value', (feat.variance || 0).toFixed(2)); + this._setText('motion-value', (feat.motion_band_power || 0).toFixed(3)); + + // Mini person-count dots + const personCount = data.estimated_persons || 0; + this._updatePersonDots(personCount); + + const presEl = document.getElementById('presence-indicator'); + const presLabel = document.getElementById('presence-label'); + if (presEl) { + const ml = cls.motion_level || 'absent'; + presEl.className = 'presence-state'; + if (ml === 'active') { presEl.classList.add('presence--active'); presLabel.textContent = 'ACTIVE'; } + else if (cls.presence) { presEl.classList.add('presence--present'); presLabel.textContent = 'PRESENT'; } + else { presEl.classList.add('presence--absent'); presLabel.textContent = 'ABSENT'; } + } + + const fallEl = document.getElementById('fall-alert'); + if (fallEl) fallEl.style.display = cls.fall_detected ? 'block' : 'none'; + + // Scenario description and edge modules + const scenarioKey = demoData._autoMode ? (demoData.currentScenario || 'auto') : (demoData.currentScenario || 'auto'); + if (scenarioKey !== this._currentScenarioKey) { + this._currentScenarioKey = scenarioKey; + this._updateScenarioDescription(scenarioKey); + this._updateEdgeModules(scenarioKey); + } + } + + // ============================================================ + // Sparkline + // ============================================================ + + updateSparkline(data) { + const rssi = data?.features?.mean_rssi; + if (rssi == null || !this._sparklineCtx) return; + this._rssiHistory.push(rssi); + if (this._rssiHistory.length > 60) this._rssiHistory.shift(); + + const ctx = this._sparklineCtx; + const w = ctx.canvas.width, h = ctx.canvas.height; + ctx.clearRect(0, 0, w, h); + if (this._rssiHistory.length < 2) return; + + ctx.beginPath(); + ctx.strokeStyle = '#2090ff'; + ctx.lineWidth = 1.5; + ctx.shadowColor = '#2090ff'; + ctx.shadowBlur = 4; + for (let i = 0; i < this._rssiHistory.length; i++) { + const x = (i / (this._rssiHistory.length - 1)) * w; + const norm = Math.max(0, Math.min(1, (this._rssiHistory[i] + 80) / 60)); + const y = h - norm * h; + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.shadowBlur = 0; + ctx.lineTo(w, h); + ctx.lineTo(0, h); + ctx.closePath(); + const grad = ctx.createLinearGradient(0, 0, 0, h); + grad.addColorStop(0, 'rgba(32,144,255,0.15)'); + grad.addColorStop(1, 'rgba(32,144,255,0)'); + ctx.fillStyle = grad; + ctx.fill(); + } + + // ============================================================ + // Private helpers + // ============================================================ + + _setText(id, val) { + const e = document.getElementById(id); + if (e) e.textContent = val; + } + + _setWidth(id, pct) { + const e = document.getElementById(id); + if (e) e.style.width = `${pct}%`; + } + + _setColor(id, color) { + const e = document.getElementById(id); + if (e) e.style.color = color; + } + + _setBarColor(id, color) { + const e = document.getElementById(id); + if (e) e.style.background = color; + } + + _bindRange(id, key, applyFn) { + const el = document.getElementById(id); + const valEl = document.getElementById(`${id}-val`); + if (!el) return; + el.value = this._obs.settings[key]; + if (valEl) valEl.textContent = this._obs.settings[key]; + el.addEventListener('input', (e) => { + const v = parseFloat(e.target.value); + this._obs.settings[key] = v; + if (valEl) valEl.textContent = v; + if (applyFn) applyFn(v); + this.saveSettings(); + }); + } + + _updatePersonDots(count) { + const container = document.getElementById('persons-dots'); + if (!container) { + // Fall back to text-only display + this._setText('persons-value', count); + return; + } + // Build dot icons: filled for detected persons, dim for empty slots (max 8) + const maxDots = 8; + const clamped = Math.min(count, maxDots); + let html = ''; + for (let i = 0; i < maxDots; i++) { + const active = i < clamped; + html += ``; + } + 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/main.js b/observatory/js/main.js new file mode 100644 index 00000000..e3aeabc1 --- /dev/null +++ b/observatory/js/main.js @@ -0,0 +1,715 @@ +/** + * RuView Observatory — Main Scene Orchestrator + * + * Room-based WiFi sensing visualization with: + * - Pool of 4 human wireframe figures (multi-person scenarios) + * - 7 pose types (standing, walking, lying, sitting, fallen, exercising, gesturing, crouching) + * - Scenario-specific room props (chair, exercise mat, door, rubble wall, screen, desk) + * - Dot-matrix mist body mass, particle trails, WiFi waves, signal field + * - Reflective floor, settings dialog, and practical data HUD + */ +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; + +import { DemoDataGenerator } from './demo-data.js'; +import { NebulaBackground } from './nebula-background.js'; +import { PostProcessing } from './post-processing.js'; +import { FigurePool, SKELETON_PAIRS } from './figure-pool.js'; +import { PoseSystem } from './pose-system.js'; +import { ScenarioProps } from './scenario-props.js'; +import { HudController, DEFAULTS, SETTINGS_VERSION, PRESETS, SCENARIO_NAMES } from './hud-controller.js'; + +// ---- Palette ---- +const C = { + greenGlow: 0x00d878, + greenBright:0x3eff8a, + greenDim: 0x0a6b3a, + amber: 0xffb020, + blueSignal: 0x2090ff, + redAlert: 0xff3040, + redHeart: 0xff4060, + bgDeep: 0x080c14, +}; + +// SCENARIO_NAMES, DEFAULTS, SETTINGS_VERSION, PRESETS imported from hud-controller.js + +// ---- Main Class ---- + +class Observatory { + constructor() { + this._canvas = document.getElementById('observatory-canvas'); + this.settings = { ...DEFAULTS }; + + // Load saved settings + try { + const ver = localStorage.getItem('ruview-settings-version'); + if (ver === SETTINGS_VERSION) { + const saved = localStorage.getItem('ruview-observatory-settings'); + if (saved) Object.assign(this.settings, JSON.parse(saved)); + } else { + localStorage.removeItem('ruview-observatory-settings'); + localStorage.setItem('ruview-settings-version', SETTINGS_VERSION); + } + } catch {} + + // Renderer + this._renderer = new THREE.WebGLRenderer({ + canvas: this._canvas, + antialias: true, + powerPreference: 'high-performance', + }); + this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this._renderer.setSize(window.innerWidth, window.innerHeight); + this._renderer.toneMapping = THREE.ACESFilmicToneMapping; + this._renderer.toneMappingExposure = this.settings.exposure; + this._renderer.shadowMap.enabled = true; + this._renderer.shadowMap.type = THREE.PCFSoftShadowMap; + + // Scene + this._scene = new THREE.Scene(); + this._scene.background = new THREE.Color(C.bgDeep); + this._scene.fog = new THREE.FogExp2(C.bgDeep, 0.005); + + // Camera + this._camera = new THREE.PerspectiveCamera( + this.settings.fov, window.innerWidth / window.innerHeight, 0.1, 300 + ); + this._camera.position.set(6, 5, 8); + this._camera.lookAt(0, 1.2, 0); + + // Controls + this._controls = new OrbitControls(this._camera, this._canvas); + this._controls.enableDamping = true; + this._controls.dampingFactor = 0.08; + this._controls.minDistance = 2; + this._controls.maxDistance = 25; + this._controls.maxPolarAngle = Math.PI * 0.88; + this._controls.target.set(0, 1.2, 0); + this._controls.update(); + + this._clock = new THREE.Clock(); + + // Data + this._demoData = new DemoDataGenerator(); + this._demoData.setCycleDuration(this.settings.cycle || 30); + if (this.settings.scenario && this.settings.scenario !== 'auto') { + this._demoData.setScenario(this.settings.scenario); + } + this._currentData = null; + this._currentScenario = null; + + // Build scene + this._setupLighting(); + this._nebula = new NebulaBackground(this._scene); + this._buildRoom(); + this._buildRouter(); + this._poseSystem = new PoseSystem(); + this._figurePool = new FigurePool(this._scene, this.settings, this._poseSystem); + this._scenarioProps = new ScenarioProps(this._scene); + this._buildDotMatrixMist(); + this._buildParticleTrail(); + this._buildWifiWaves(); + this._buildSignalField(); + + // Post-processing + this._postProcessing = new PostProcessing(this._renderer, this._scene, this._camera); + this._applyPostSettings(); + + // HUD controller (settings dialog, sparkline, vital displays) + this._hud = new HudController(this); + + // State + this._autopilot = false; + this._autoAngle = 0; + this._fpsFrames = 0; + this._fpsTime = 0; + this._fpsValue = 60; + this._showFps = false; + this._qualityLevel = 2; + + // WebSocket for live data — always try auto-detect on startup + this._ws = null; + this._liveData = null; + this._autoDetectLive(); + + // Input + this._initKeyboard(); + this._hud.initSettings(); + this._hud.initQuickSelect(); + window.addEventListener('resize', () => this._onResize()); + + // Start + this._animate(); + } + + // ---- Lighting ---- + + _setupLighting() { + this._ambient = new THREE.AmbientLight(0x446688, this.settings.ambient * 3.0); + this._scene.add(this._ambient); + + const hemi = new THREE.HemisphereLight(0x6688bb, 0x203040, 1.2); + this._scene.add(hemi); + + const key = new THREE.DirectionalLight(0xffeedd, 1.2); + key.position.set(4, 8, 3); + key.castShadow = true; + key.shadow.mapSize.set(1024, 1024); + key.shadow.camera.near = 0.5; + key.shadow.camera.far = 20; + key.shadow.camera.left = -8; + key.shadow.camera.right = 8; + key.shadow.camera.top = 8; + key.shadow.camera.bottom = -8; + this._scene.add(key); + + // Fill light from opposite side + const fill = new THREE.DirectionalLight(0x8899bb, 0.7); + fill.position.set(-4, 5, -2); + this._scene.add(fill); + + // Rim light from above/behind for edge definition + const rim = new THREE.DirectionalLight(0x6699cc, 0.5); + rim.position.set(0, 6, -5); + this._scene.add(rim); + + // Overhead room light — general illumination + const overhead = new THREE.PointLight(0x8899aa, 1.0, 20, 1.0); + overhead.position.set(0, 3.8, 0); + this._scene.add(overhead); + } + + // ---- Room ---- + + _buildRoom() { + this._grid = new THREE.GridHelper(12, 24, 0x1a4830, 0x0c2818); + this._grid.material.opacity = 0.5; + this._grid.material.transparent = true; + this._scene.add(this._grid); + + const boxGeo = new THREE.BoxGeometry(12, 4, 10); + const edges = new THREE.EdgesGeometry(boxGeo); + this._roomWire = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ + color: C.greenDim, opacity: 0.3, transparent: true, + })); + this._roomWire.position.y = 2; + this._scene.add(this._roomWire); + + // Reflective floor + const floorGeo = new THREE.PlaneGeometry(12, 10); + this._floorMat = new THREE.MeshStandardMaterial({ + color: 0x101810, + roughness: 1.0 - this.settings.reflect * 0.7, + metalness: this.settings.reflect * 0.5, + emissive: 0x020404, + emissiveIntensity: 0.08, + }); + const floor = new THREE.Mesh(floorGeo, this._floorMat); + floor.rotation.x = -Math.PI / 2; + floor.receiveShadow = true; + this._scene.add(floor); + + // Table under router + const tableGeo = new THREE.BoxGeometry(0.8, 0.6, 0.5); + const tableMat = new THREE.MeshStandardMaterial({ color: 0x6b5840, roughness: 0.55, emissive: 0x1a1408, emissiveIntensity: 0.25 }); + const table = new THREE.Mesh(tableGeo, tableMat); + table.position.set(-4, 0.3, -3); + table.castShadow = true; + this._scene.add(table); + } + + // ---- Router ---- + + _buildRouter() { + this._routerGroup = new THREE.Group(); + this._routerGroup.position.set(-4, 0.92, -3); + + const bodyGeo = new THREE.BoxGeometry(0.6, 0.12, 0.35); + const bodyMat = new THREE.MeshStandardMaterial({ color: 0x505060, roughness: 0.2, metalness: 0.7, emissive: 0x101018, emissiveIntensity: 0.2 }); + this._routerGroup.add(new THREE.Mesh(bodyGeo, bodyMat)); + + for (let i = -1; i <= 1; i++) { + const antGeo = new THREE.CylinderGeometry(0.015, 0.015, 0.35); + const antMat = new THREE.MeshStandardMaterial({ color: 0x606068, roughness: 0.3, metalness: 0.6, emissive: 0x101018, emissiveIntensity: 0.15 }); + const ant = new THREE.Mesh(antGeo, antMat); + ant.position.set(i * 0.2, 0.24, 0); + ant.rotation.z = i * 0.15; + this._routerGroup.add(ant); + } + + const ledGeo = new THREE.SphereGeometry(0.025); + this._routerLed = new THREE.Mesh(ledGeo, new THREE.MeshBasicMaterial({ color: C.greenGlow })); + this._routerLed.position.set(0.22, 0.07, 0.18); + this._routerGroup.add(this._routerLed); + + this._routerLight = new THREE.PointLight(C.blueSignal, 1.2, 8); + this._routerLight.position.set(0, 0.3, 0); + this._routerGroup.add(this._routerLight); + + this._scene.add(this._routerGroup); + } + + // ---- WiFi Waves ---- + + _buildWifiWaves() { + this._wifiWaves = []; + for (let i = 0; i < 5; i++) { + const radius = 0.8 + i * 1.0; + const geo = new THREE.SphereGeometry(radius, 24, 16, 0, Math.PI * 2, 0, Math.PI * 0.6); + const mat = new THREE.MeshBasicMaterial({ + color: C.blueSignal, + transparent: true, opacity: 0, + side: THREE.DoubleSide, + blending: THREE.AdditiveBlending, + depthWrite: false, wireframe: true, + }); + const shell = new THREE.Mesh(geo, mat); + shell.position.copy(this._routerGroup.position); + shell.position.y += 0.5; + this._scene.add(shell); + this._wifiWaves.push({ mesh: shell, mat, phase: i * 0.7 }); + } + } + + // ======================================== + // DOT MATRIX MIST + // ======================================== + + _buildDotMatrixMist() { + const COUNT = 800; + const positions = new Float32Array(COUNT * 3); + const alphas = new Float32Array(COUNT); + for (let i = 0; i < COUNT; i++) { + const angle = Math.random() * Math.PI * 2; + const r = Math.random() * 0.5; + positions[i * 3] = Math.cos(angle) * r; + positions[i * 3 + 1] = Math.random() * 1.8; + positions[i * 3 + 2] = Math.sin(angle) * r; + alphas[i] = 0; + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('alpha', new THREE.BufferAttribute(alphas, 1)); + const mat = new THREE.ShaderMaterial({ + vertexShader: ` + attribute float alpha; + varying float vAlpha; + void main() { + vAlpha = alpha; + vec4 mv = modelViewMatrix * vec4(position, 1.0); + gl_PointSize = 3.0 * (200.0 / -mv.z); + gl_Position = projectionMatrix * mv; + } + `, + fragmentShader: ` + uniform vec3 uColor; + varying float vAlpha; + void main() { + float d = length(gl_PointCoord - 0.5); + if (d > 0.5) discard; + float edge = smoothstep(0.5, 0.2, d); + gl_FragColor = vec4(uColor, edge * vAlpha); + } + `, + uniforms: { uColor: { value: new THREE.Color(this.settings.wireColor) } }, + transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, + }); + this._mistPoints = new THREE.Points(geo, mat); + this._scene.add(this._mistPoints); + this._mistCount = COUNT; + } + + // ---- Particle Trail ---- + + _buildParticleTrail() { + const COUNT = 200; + const positions = new Float32Array(COUNT * 3); + const ages = new Float32Array(COUNT); + for (let i = 0; i < COUNT; i++) ages[i] = 1; + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('age', new THREE.BufferAttribute(ages, 1)); + const mat = new THREE.ShaderMaterial({ + vertexShader: ` + attribute float age; + varying float vAge; + void main() { + vAge = age; + vec4 mv = modelViewMatrix * vec4(position, 1.0); + gl_PointSize = max(1.0, (1.0 - age) * 5.0 * (150.0 / -mv.z)); + gl_Position = projectionMatrix * mv; + } + `, + fragmentShader: ` + uniform vec3 uColor; + varying float vAge; + void main() { + float d = length(gl_PointCoord - 0.5); + if (d > 0.5) discard; + float alpha = (1.0 - vAge) * 0.6 * smoothstep(0.5, 0.1, d); + gl_FragColor = vec4(uColor, alpha); + } + `, + uniforms: { uColor: { value: new THREE.Color(C.greenGlow) } }, + transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, + }); + this._trail = new THREE.Points(geo, mat); + this._scene.add(this._trail); + this._trailHead = 0; + this._trailCount = COUNT; + this._trailTimer = 0; + } + + // ---- Signal Field ---- + + _buildSignalField() { + const gridSize = 20; + const count = gridSize * gridSize; + const positions = new Float32Array(count * 3); + this._fieldColors = new Float32Array(count * 3); + this._fieldSizes = new Float32Array(count); + for (let iz = 0; iz < gridSize; iz++) { + for (let ix = 0; ix < gridSize; ix++) { + const idx = iz * gridSize + ix; + positions[idx * 3] = (ix - gridSize / 2) * 0.6; + positions[idx * 3 + 1] = 0.02; + positions[idx * 3 + 2] = (iz - gridSize / 2) * 0.5; + this._fieldSizes[idx] = 8; + } + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('color', new THREE.BufferAttribute(this._fieldColors, 3)); + geo.setAttribute('size', new THREE.BufferAttribute(this._fieldSizes, 1)); + this._fieldMat = new THREE.PointsMaterial({ + size: 0.35, vertexColors: true, transparent: true, + opacity: this.settings.field, blending: THREE.AdditiveBlending, + depthWrite: false, sizeAttenuation: true, + }); + this._fieldPoints = new THREE.Points(geo, this._fieldMat); + this._scene.add(this._fieldPoints); + } + + // ---- Keyboard ---- + + _initKeyboard() { + window.addEventListener('keydown', (e) => { + if (this._hud.settingsOpen) return; + switch (e.key.toLowerCase()) { + case 'a': + this._autopilot = !this._autopilot; + this._controls.enabled = !this._autopilot; + break; + case 'd': this._demoData.cycleScenario(); break; + case 'f': + this._showFps = !this._showFps; + document.getElementById('fps-counter').style.display = this._showFps ? 'block' : 'none'; + break; + case 's': this._hud.toggleSettings(); break; + case ' ': + e.preventDefault(); + this._demoData.paused = !this._demoData.paused; + break; + } + }); + } + + // ---- Settings / HUD methods delegated to HudController ---- + + _applyPostSettings() { + const pp = this._postProcessing; + pp._bloomPass.strength = this.settings.bloom; + pp._bloomPass.radius = this.settings.bloomRadius; + pp._bloomPass.threshold = this.settings.bloomThresh; + pp._vignettePass.uniforms.uVignetteStrength.value = this.settings.vignette; + pp._vignettePass.uniforms.uGrainStrength.value = this.settings.grain; + pp._vignettePass.uniforms.uChromaticStrength.value = this.settings.chromatic; + } + + _applyColors() { + const wc = new THREE.Color(this.settings.wireColor); + const jc = new THREE.Color(this.settings.jointColor); + this._figurePool.applyColors(wc, jc); + this._mistPoints.material.uniforms.uColor.value.copy(wc); + } + + // ---- WebSocket live data ---- + + _autoDetectLive() { + // Probe sensing server health on same origin, then common ports + const host = window.location.hostname || 'localhost'; + const candidates = [ + window.location.origin, // same origin (e.g. :3000) + `http://${host}:8765`, // default WS port + `http://${host}:3000`, // default HTTP port + ]; + // Deduplicate + const unique = [...new Set(candidates)]; + + const tryNext = (i) => { + if (i >= unique.length) { + console.log('[Observatory] No sensing server detected, using demo mode'); + return; + } + const base = unique[i]; + fetch(`${base}/health`, { signal: AbortSignal.timeout(1500) }) + .then(r => r.ok ? r.json() : Promise.reject()) + .then(data => { + if (data && data.status === 'ok') { + const wsProto = base.startsWith('https') ? 'wss:' : 'ws:'; + const urlObj = new URL(base); + const wsUrl = `${wsProto}//${urlObj.host}/ws/sensing`; + console.log('[Observatory] Sensing server detected at', base, '→', wsUrl); + this.settings.dataSource = 'ws'; + this.settings.wsUrl = wsUrl; + this._connectWS(wsUrl); + } else { + tryNext(i + 1); + } + }) + .catch(() => tryNext(i + 1)); + }; + tryNext(0); + } + + _connectWS(url) { + this._disconnectWS(); + try { + this._ws = new WebSocket(url); + this._ws.onopen = () => { + console.log('[Observatory] WebSocket connected'); + this._hud.updateSourceBadge('ws', this._ws); + }; + this._ws.onmessage = (evt) => { try { this._liveData = JSON.parse(evt.data); } catch {} }; + this._ws.onclose = () => { + console.log('[Observatory] WebSocket closed, falling back to demo'); + this._ws = null; + this.settings.dataSource = 'demo'; + this._hud.updateSourceBadge('demo', null); + }; + this._ws.onerror = () => {}; + } catch {} + } + + _disconnectWS() { + if (this._ws) { this._ws.close(); this._ws = null; } + this._liveData = null; + } + + // ======================================== + // ANIMATION LOOP + // ======================================== + + _animate() { + requestAnimationFrame(() => this._animate()); + const dt = Math.min(this._clock.getDelta(), 0.1); + const elapsed = this._clock.getElapsedTime(); + + // Data source + if (this.settings.dataSource === 'ws' && this._liveData) { + this._currentData = this._liveData; + } else { + this._currentData = this._demoData.update(dt); + } + const data = this._currentData; + + // Updates + this._nebula.update(dt, elapsed); + this._figurePool.update(data, elapsed); + this._scenarioProps.update(data, this._demoData.currentScenario); + this._updateDotMatrixMist(data, elapsed); + this._updateParticleTrail(data, dt, elapsed); + this._updateWifiWaves(elapsed); + this._updateSignalField(data); + this._hud.updateHUD(data, this._demoData); + this._hud.updateSparkline(data); + + // Router LED + this._routerLed.material.opacity = 0.5 + 0.5 * Math.sin(elapsed * 8); + this._routerLight.intensity = 0.3 + 0.2 * Math.sin(elapsed * 3); + + // Autopilot orbit + if (this._autopilot) { + this._autoAngle += dt * this.settings.orbitSpeed; + const r = 10; + this._camera.position.set( + Math.sin(this._autoAngle) * r, + 4.5 + Math.sin(this._autoAngle * 0.5), + Math.cos(this._autoAngle) * r + ); + this._controls.target.set(0, 1.2, 0); + this._controls.update(); + } + this._controls.update(); + this._postProcessing.update(elapsed); + this._postProcessing.render(); + this._updateFPS(dt); + } + + + // ======================================== + // MIST & TRAIL + // ======================================== + + _updateDotMatrixMist(data, elapsed) { + const persons = data?.persons || []; + const isPresent = data?.classification?.presence || false; + const pos = this._mistPoints.geometry.attributes.position; + const alpha = this._mistPoints.geometry.attributes.alpha; + + if (!isPresent || persons.length === 0) { + for (let i = 0; i < this._mistCount; i++) { + alpha.array[i] = Math.max(0, alpha.array[i] - 0.02); + } + alpha.needsUpdate = true; + return; + } + + // Follow primary person + const pp = persons[0].position || [0, 0, 0]; + const px = pp[0] || 0, pz = pp[2] || 0; + const ms = persons[0].motion_score || 0; + const pose = persons[0].pose || 'standing'; + const isLying = pose === 'lying' || pose === 'fallen'; + const bodyH = isLying ? 0.4 : 1.7; + const bodyBaseY = isLying ? (pp[1] || 0) + 0.05 : 0.05; + const spread = ms > 50 ? 0.6 : 0.4; + + for (let i = 0; i < this._mistCount; i++) { + const drift = Math.sin(elapsed * 0.5 + i * 0.1) * 0.003; + const angle = (i / this._mistCount) * Math.PI * 2 + elapsed * 0.1; + const layerT = (i % 20) / 20; + const layerY = bodyBaseY + layerT * bodyH; + + let bodyWidth; + if (isLying) { + bodyWidth = 0.25; + } else { + bodyWidth = layerT > 0.75 ? 0.15 : (layerT > 0.45 ? 0.25 : 0.18); + } + const r = bodyWidth * (0.5 + 0.5 * Math.sin(i * 1.7 + elapsed * 0.3)) * spread; + + const tx = px + Math.cos(angle + i * 0.3) * r + drift; + const tz = pz + Math.sin(angle + i * 0.5) * r * 0.6; + + pos.array[i * 3] += (tx - pos.array[i * 3]) * 0.05; + pos.array[i * 3 + 1] += (layerY - pos.array[i * 3 + 1]) * 0.05; + pos.array[i * 3 + 2] += (tz - pos.array[i * 3 + 2]) * 0.05; + + const targetAlpha = 0.15 + Math.sin(elapsed * 2 + i * 0.5) * 0.08; + alpha.array[i] += (targetAlpha - alpha.array[i]) * 0.08; + } + pos.needsUpdate = true; + alpha.needsUpdate = true; + } + + _updateParticleTrail(data, dt, elapsed) { + if (this.settings.trail <= 0) return; + const persons = data?.persons || []; + const isPresent = data?.classification?.presence || false; + const pos = this._trail.geometry.attributes.position; + const ages = this._trail.geometry.attributes.age; + + for (let i = 0; i < this._trailCount; i++) { + ages.array[i] = Math.min(1, ages.array[i] + dt * 0.8); + } + + // Emit from all active persons + if (isPresent && persons.length > 0) { + this._trailTimer += dt; + const ms = persons[0].motion_score || 0; + const emitRate = ms > 50 ? 0.02 : 0.08; + + if (this._trailTimer >= emitRate) { + this._trailTimer = 0; + for (const p of persons) { + const pp = p.position || [0, 0, 0]; + const idx = this._trailHead; + pos.array[idx * 3] = (pp[0] || 0) + (Math.random() - 0.5) * 0.15; + pos.array[idx * 3 + 1] = Math.random() * 1.5 + 0.1; + pos.array[idx * 3 + 2] = (pp[2] || 0) + (Math.random() - 0.5) * 0.15; + ages.array[idx] = 0; + this._trailHead = (this._trailHead + 1) % this._trailCount; + } + } + } + pos.needsUpdate = true; + ages.needsUpdate = true; + } + + // ---- WiFi Waves ---- + + _updateWifiWaves(elapsed) { + for (const w of this._wifiWaves) { + const t = (elapsed * 0.8 + w.phase) % 4.5; + const life = t / 4.5; + w.mat.opacity = Math.max(0, this.settings.waves * 0.25 * (1 - life)); + const scale = 1 + life * 0.6; + w.mesh.scale.set(scale, scale, scale); + w.mesh.rotation.y = elapsed * 0.05; + } + } + + // ---- Signal Field ---- + + _updateSignalField(data) { + const field = data?.signal_field?.values; + if (!field) return; + const count = Math.min(field.length, 400); + for (let i = 0; i < count; i++) { + const v = field[i] || 0; + let r, g, b; + if (v < 0.3) { r = 0; g = v * 1.5; b = v * 0.3; } + else if (v < 0.6) { + const t = (v - 0.3) / 0.3; + r = t * 0.3; g = 0.45 + t * 0.4; b = 0.09 - t * 0.05; + } else { + const t = (v - 0.6) / 0.4; + r = 0.3 + t * 0.7; g = 0.85 - t * 0.2; b = 0.04; + } + this._fieldColors[i * 3] = r; + this._fieldColors[i * 3 + 1] = g; + this._fieldColors[i * 3 + 2] = b; + this._fieldSizes[i] = 5 + v * 15; + } + this._fieldPoints.geometry.attributes.color.needsUpdate = true; + this._fieldPoints.geometry.attributes.size.needsUpdate = true; + } + + // ---- FPS ---- + + _updateFPS(dt) { + this._fpsFrames++; + this._fpsTime += dt; + if (this._fpsTime >= 1) { + this._fpsValue = Math.round(this._fpsFrames / this._fpsTime); + this._fpsFrames = 0; + this._fpsTime = 0; + if (this._showFps) { + document.getElementById('fps-counter').textContent = `${this._fpsValue} FPS`; + } + this._adaptQuality(); + } + } + + _adaptQuality() { + let nl = this._qualityLevel; + if (this._fpsValue < 25 && nl > 0) nl--; + else if (this._fpsValue > 55 && nl < 2) nl++; + if (nl !== this._qualityLevel) { + this._qualityLevel = nl; + this._nebula.setQuality(nl); + this._postProcessing.setQuality(nl); + } + } + + _onResize() { + const w = window.innerWidth, h = window.innerHeight; + this._camera.aspect = w / h; + this._camera.updateProjectionMatrix(); + this._renderer.setSize(w, h); + this._postProcessing.resize(w, h); + } +} + +new Observatory(); diff --git a/observatory/js/nebula-background.js b/observatory/js/nebula-background.js new file mode 100644 index 00000000..98ad7e84 --- /dev/null +++ b/observatory/js/nebula-background.js @@ -0,0 +1,115 @@ +/** + * 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/phase-constellation.js b/observatory/js/phase-constellation.js new file mode 100644 index 00000000..c38444dd --- /dev/null +++ b/observatory/js/phase-constellation.js @@ -0,0 +1,170 @@ +/** + * 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/pose-system.js b/observatory/js/pose-system.js new file mode 100644 index 00000000..4ed74f54 --- /dev/null +++ b/observatory/js/pose-system.js @@ -0,0 +1,567 @@ +/** + * PoseSystem -- Stateless pose keypoint generator for COCO 17-keypoint format. + * + * Keypoint indices: + * 0:nose 1:left_eye 2:right_eye 3:left_ear 4:right_ear + * 5:left_shoulder 6:right_shoulder 7:left_elbow 8:right_elbow + * 9:left_wrist 10:right_wrist 11:left_hip 12:right_hip + * 13:left_knee 14:right_knee 15:left_ankle 16:right_ankle + * + * Every public method is a pure function: parameters in, keypoint array out. + */ + +export class PoseSystem { + + // ---- Entry point ------------------------------------------------------- + + generateKeypoints(person, elapsed, breathPulse) { + const pose = person.pose || 'standing'; + const pos = person.position || [0, 0, 0]; + const facing = person.facing || 0; + const px = pos[0], pz = pos[2]; + const ms = person.motion_score || 0; + const bp = breathPulse; + + let kps; + switch (pose) { + case 'lying': kps = this.poseLying(px, pos[1] || 0, pz, elapsed, bp); break; + case 'sitting': kps = this.poseSitting(px, pz, elapsed, bp); break; + case 'fallen': kps = this.poseFallen(px, pz, elapsed); break; + case 'falling': kps = this.poseFalling(px, pz, elapsed, person.fallProgress || 0); break; + case 'exercising': kps = this.poseExercising(px, pz, elapsed, person.exerciseType, person.exerciseTime); break; + case 'gesturing': kps = this.poseGesturing(px, pz, elapsed, person.gestureType, person.gestureIntensity || 0); break; + case 'crouching': kps = this.poseCrouching(px, pz, elapsed, bp); break; + case 'walking': kps = this.poseWalking(px, pz, elapsed, ms, bp); break; + case 'standing': + default: kps = this.poseStanding(px, pz, elapsed, ms, bp); break; + } + + // Apply facing rotation + if (Math.abs(facing) > 0.01) { + this.rotateKps(kps, px, pz, facing); + } + return kps; + } + + // ---- Rotation utility -------------------------------------------------- + + rotateKps(kps, cx, cz, angle) { + const cos = Math.cos(angle), sin = Math.sin(angle); + for (const kp of kps) { + const dx = kp[0] - cx, dz = kp[2] - cz; + kp[0] = cx + dx * cos - dz * sin; + kp[2] = cz + dx * sin + dz * cos; + } + } + + // ---- Standing ---------------------------------------------------------- + // Weight shift between feet, idle head look-around, breathing + + poseStanding(px, pz, elapsed, ms, bp) { + // Slow weight shift side to side + const weightShift = Math.sin(elapsed * 0.6) * 0.012; + // Idle head look around + const headTurn = Math.sin(elapsed * 0.3) * 0.015; + const headTilt = Math.cos(elapsed * 0.25) * 0.008; + // Slight sway from micro-balance adjustments + const sway = Math.sin(elapsed * 0.8) * 0.005 + weightShift; + // Knee bend alternation with weight shift + const leftKneeBend = Math.max(0, Math.sin(elapsed * 0.6)) * 0.015; + const rightKneeBend = Math.max(0, -Math.sin(elapsed * 0.6)) * 0.015; + + return [ + [px + sway + headTurn, 1.72 + bp + headTilt, pz], // 0 nose + [px - 0.03 + sway + headTurn, 1.74 + bp + headTilt, pz - 0.02], // 1 left eye + [px + 0.03 + sway + headTurn, 1.74 + bp + headTilt, pz - 0.02], // 2 right eye + [px - 0.07 + headTurn * 0.5, 1.72 + bp, pz], // 3 left ear + [px + 0.07 + headTurn * 0.5, 1.72 + bp, pz], // 4 right ear + [px - 0.22 + weightShift * 0.3, 1.48 + bp, pz], // 5 left shoulder + [px + 0.22 + weightShift * 0.3, 1.48 + bp, pz], // 6 right shoulder + [px - 0.24 + weightShift * 0.2, 1.18 + bp, pz + 0.02], // 7 left elbow + [px + 0.24 + weightShift * 0.2, 1.18 + bp, pz - 0.02], // 8 right elbow + [px - 0.22 + weightShift * 0.15, 0.92 + bp, pz + 0.05], // 9 left wrist + [px + 0.22 + weightShift * 0.15, 0.92 + bp, pz - 0.05], // 10 right wrist + [px - 0.11 + weightShift * 0.5, 0.98 + bp, pz], // 11 left hip + [px + 0.11 + weightShift * 0.5, 0.98 + bp, pz], // 12 right hip + [px - 0.12 + weightShift * 0.3, 0.52 + leftKneeBend, pz], // 13 left knee + [px + 0.12 + weightShift * 0.3, 0.52 + rightKneeBend, pz], // 14 right knee + [px - 0.12 + weightShift * 0.4, 0.04, pz], // 15 left ankle + [px + 0.12 + weightShift * 0.4, 0.04, pz], // 16 right ankle + ]; + } + + // ---- Walking ----------------------------------------------------------- + // Torso rotation, head bob, natural arm pendulum with elbow bend + + poseWalking(px, pz, elapsed, ms, bp) { + const speed = Math.min(ms / 100, 2.5); + const wp = elapsed * speed * 1.8; + const sFactor = Math.min(speed, 1); + + // Leg stride + const legStride = Math.sin(wp) * 0.25 * sFactor; + const legBack = Math.sin(wp + Math.PI) * 0.25 * sFactor; + const kneeAmt = Math.abs(Math.sin(wp)) * 0.08; + + // Natural arm pendulum -- opposite to legs, with elbow bend + const armPhase = Math.sin(wp); + const armSwingL = -armPhase * 0.3 * sFactor; // left arm opposite right leg + const armSwingR = armPhase * 0.3 * sFactor; + const elbowBendL = Math.max(0, -armPhase) * 0.12 * sFactor; // bend on backswing + const elbowBendR = Math.max(0, armPhase) * 0.12 * sFactor; + + // Torso twist (shoulders rotate opposite to hips) + const torsoTwist = Math.sin(wp) * 0.03 * sFactor; + + // Vertical bob (double frequency -- peak at mid-stance) + const bob = Math.abs(Math.sin(wp)) * 0.025; + + // Head bob -- slight lag behind body + const headBob = Math.abs(Math.sin(wp - 0.2)) * 0.015; + const headLean = Math.sin(wp) * 0.008; + + return [ + [px + headLean, 1.72 + bp + bob + headBob, pz], // 0 nose + [px - 0.03 + headLean, 1.74 + bp + bob + headBob, pz - 0.02], // 1 left eye + [px + 0.03 + headLean, 1.74 + bp + bob + headBob, pz - 0.02], // 2 right eye + [px - 0.07, 1.72 + bp + bob + headBob, pz], // 3 left ear + [px + 0.07, 1.72 + bp + bob + headBob, pz], // 4 right ear + [px - 0.22 - torsoTwist, 1.48 + bp + bob, pz], // 5 left shoulder (twist) + [px + 0.22 - torsoTwist, 1.48 + bp + bob, pz], // 6 right shoulder + [px - 0.28 + armSwingL * 0.3, 1.18 + bp + bob - elbowBendL, pz + armSwingL * 0.3], // 7 left elbow + [px + 0.28 + armSwingR * 0.3, 1.18 + bp + bob - elbowBendR, pz + armSwingR * 0.3], // 8 right elbow + [px - 0.26 + armSwingL * 0.6, 0.92 + bp + bob - elbowBendL * 1.5, pz + armSwingL * 0.5], // 9 left wrist + [px + 0.26 + armSwingR * 0.6, 0.92 + bp + bob - elbowBendR * 1.5, pz + armSwingR * 0.5], // 10 right wrist + [px - 0.11 + torsoTwist * 0.5, 0.98 + bp + bob, pz], // 11 left hip (counter-twist) + [px + 0.11 + torsoTwist * 0.5, 0.98 + bp + bob, pz], // 12 right hip + [px - 0.12 + legStride * 0.3, 0.52 + kneeAmt, pz + legStride], // 13 left knee + [px + 0.12 + legBack * 0.3, 0.52 + kneeAmt, pz + legBack], // 14 right knee + [px - 0.12 + legStride * 0.6, 0.04, pz + legStride * 1.5], // 15 left ankle + [px + 0.12 + legBack * 0.6, 0.04, pz + legBack * 1.5], // 16 right ankle + ]; + } + + // ---- Lying ------------------------------------------------------------- + // Subtle micro-movements, differentiate supine vs side-lying via elapsed hash + + poseLying(px, surfaceY, pz, elapsed, bp) { + const y = (surfaceY || 0) + 0.2; + const chest = bp * 0.015; + + // Micro-movements -- tiny random-feeling shifts (deterministic from elapsed) + const microX = Math.sin(elapsed * 0.17) * 0.004; + const microZ = Math.cos(elapsed * 0.13) * 0.003; + const fingerTwitch = Math.sin(elapsed * 0.7) * 0.008; + + // Determine supine vs side-lying from a slow oscillation (stays one way for ~20s) + const lyingMode = Math.sin(elapsed * 0.05); + + if (lyingMode > 0.3) { + // Side-lying (on left side) + const curl = Math.sin(elapsed * 0.1) * 0.02; // slight fetal curl + return [ + [px - 0.72 + microX, y + 0.12, pz - 0.08], // 0 nose (turned) + [px - 0.70, y + 0.14, pz - 0.10], // 1 left eye + [px - 0.70, y + 0.16, pz - 0.06], // 2 right eye (up) + [px - 0.76, y + 0.11, pz - 0.12], // 3 left ear (down) + [px - 0.76, y + 0.14, pz - 0.04], // 4 right ear + [px - 0.45, y + chest + 0.05, pz - 0.12], // 5 left shoulder (down) + [px - 0.45, y + chest + 0.2, pz + 0.04], // 6 right shoulder (up) + [px - 0.38, y + 0.02, pz - 0.28 + curl], // 7 left elbow + [px - 0.35, y + 0.18, pz + 0.15 + fingerTwitch], // 8 right elbow + [px - 0.20, y - 0.01, pz - 0.30 + curl], // 9 left wrist + [px - 0.18, y + 0.12, pz + 0.25 + fingerTwitch], // 10 right wrist + [px + 0.05 + microX, y + chest * 0.4 + 0.03, pz - 0.08], // 11 left hip + [px + 0.05 + microX, y + chest * 0.4 + 0.12, pz + 0.06], // 12 right hip + [px + 0.40 + curl * 2, y + 0.02, pz - 0.14 + curl], // 13 left knee + [px + 0.38 + curl * 2, y + 0.10, pz + 0.10 + curl], // 14 right knee + [px + 0.75, y - 0.01, pz - 0.12], // 15 left ankle + [px + 0.72, y + 0.04, pz + 0.08], // 16 right ankle + ]; + } + + // Supine (face up) -- default + return [ + [px - 0.75 + microX, y + 0.08, pz + microZ], // 0 nose + [px - 0.72, y + 0.1, pz - 0.02 + microZ], // 1 left eye + [px - 0.72, y + 0.1, pz + 0.02 + microZ], // 2 right eye + [px - 0.78, y + 0.08, pz - 0.05], // 3 left ear + [px - 0.78, y + 0.08, pz + 0.05], // 4 right ear + [px - 0.45, y + chest, pz - 0.18], // 5 left shoulder + [px - 0.45, y + chest, pz + 0.18], // 6 right shoulder + [px - 0.42, y, pz - 0.35 + fingerTwitch], // 7 left elbow + [px - 0.42, y, pz + 0.35 - fingerTwitch], // 8 right elbow + [px - 0.2, y - 0.02, pz - 0.38 + fingerTwitch], // 9 left wrist + [px - 0.2, y - 0.02, pz + 0.38 - fingerTwitch], // 10 right wrist + [px + 0.05 + microX, y + chest * 0.5, pz - 0.1], // 11 left hip + [px + 0.05 + microX, y + chest * 0.5, pz + 0.1], // 12 right hip + [px + 0.45, y, pz - 0.11], // 13 left knee + [px + 0.45, y, pz + 0.11], // 14 right knee + [px + 0.82, y - 0.02, pz - 0.1], // 15 left ankle + [px + 0.82, y - 0.02, pz + 0.1], // 16 right ankle + ]; + } + + // ---- Sitting ----------------------------------------------------------- + // Occasional fidget, breathing chest expansion, weight shift + + poseSitting(px, pz, elapsed, bp) { + const sway = Math.sin(elapsed * 0.5) * 0.003; + + // Fidget: occasional hand movement (every ~6s a small gesture) + const fidgetCycle = elapsed % 6.0; + const fidgetActive = fidgetCycle > 5.2 && fidgetCycle < 5.8; + const fidgetAmt = fidgetActive ? Math.sin((fidgetCycle - 5.2) * Math.PI / 0.6) * 0.06 : 0; + + // Weight shift side to side (slow) + const weightShift = Math.sin(elapsed * 0.25) * 0.008; + + // Chest expansion from breathing + const chestExpand = bp * 0.008; + + return [ + [px + sway + weightShift, 1.15 + bp, pz], // 0 nose + [px - 0.03 + sway + weightShift, 1.17 + bp, pz - 0.02], // 1 left eye + [px + 0.03 + sway + weightShift, 1.17 + bp, pz - 0.02], // 2 right eye + [px - 0.07 + weightShift, 1.15 + bp, pz], // 3 left ear + [px + 0.07 + weightShift, 1.15 + bp, pz], // 4 right ear + [px - 0.20 - chestExpand + weightShift, 0.95 + bp, pz], // 5 left shoulder + [px + 0.20 + chestExpand + weightShift, 0.95 + bp, pz], // 6 right shoulder + [px - 0.25 + weightShift, 0.72 + bp, pz + 0.08], // 7 left elbow + [px + 0.25 + weightShift, 0.72 + bp, pz + 0.08], // 8 right elbow + [px - 0.18 + fidgetAmt, 0.55 + fidgetAmt * 0.3, pz + 0.15], // 9 left wrist (fidgets) + [px + 0.18, 0.55, pz + 0.15], // 10 right wrist + [px - 0.11 + weightShift * 0.5, 0.48, pz + 0.02], // 11 left hip + [px + 0.11 + weightShift * 0.5, 0.48, pz + 0.02], // 12 right hip + [px - 0.12, 0.48, pz + 0.4], // 13 left knee + [px + 0.12, 0.48, pz + 0.4], // 14 right knee + [px - 0.12, 0.04, pz + 0.4], // 15 left ankle + [px + 0.12, 0.04, pz + 0.4], // 16 right ankle + ]; + } + + // ---- Fallen ------------------------------------------------------------ + // Occasional twitch/attempt to move, asymmetric breathing + + poseFallen(px, pz, elapsed) { + // Irregular twitch -- sharper, less periodic + const twitchArm = Math.sin(elapsed * 0.3) * 0.003 + + Math.sin(elapsed * 1.7) * 0.008 * Math.max(0, Math.sin(elapsed * 0.15)); + const twitchLeg = Math.cos(elapsed * 0.4) * 0.005 * + Math.max(0, Math.sin(elapsed * 0.2 + 1.0)); + + // Asymmetric breathing (one side of chest rises more) + const breathL = Math.sin(elapsed * 0.8) * 0.006; + const breathR = Math.sin(elapsed * 0.8 + 0.3) * 0.004; + + // Attempt to move (slow reach every ~10s) + const attemptCycle = elapsed % 10.0; + const attempting = attemptCycle > 8.0 && attemptCycle < 9.5; + const attemptAmt = attempting ? Math.sin((attemptCycle - 8.0) * Math.PI / 1.5) * 0.05 : 0; + + return [ + [px + 0.35, 0.12, pz + 0.15 + twitchArm], // 0 nose + [px + 0.33, 0.14, pz + 0.13], // 1 left eye + [px + 0.37, 0.14, pz + 0.17], // 2 right eye + [px + 0.38, 0.11, pz + 0.1], // 3 left ear + [px + 0.38, 0.11, pz + 0.2], // 4 right ear + [px + 0.15, 0.15 + breathL, pz - 0.1], // 5 left shoulder + [px + 0.15, 0.2 + breathR, pz + 0.25], // 6 right shoulder + [px - 0.05, 0.08, pz - 0.25 + twitchArm], // 7 left elbow + [px + 0.3, 0.22 + attemptAmt * 0.5, pz + 0.45 + attemptAmt], // 8 right elbow (reaching) + [px - 0.15, 0.05, pz - 0.3 + twitchArm * 1.5], // 9 left wrist + [px + 0.4, 0.15 + attemptAmt, pz + 0.5 + attemptAmt * 1.5], // 10 right wrist (reaching) + [px - 0.05, 0.12, pz - 0.05], // 11 left hip + [px - 0.05, 0.12, pz + 0.15], // 12 right hip + [px - 0.2, 0.08 + twitchLeg, pz - 0.3], // 13 left knee + [px - 0.15, 0.15, pz + 0.35 + twitchLeg], // 14 right knee + [px - 0.35, 0.04, pz - 0.2], // 15 left ankle + [px - 0.3, 0.04, pz + 0.5], // 16 right ankle + ]; + } + + // ---- Falling ----------------------------------------------------------- + // Flailing arms, head snap, non-linear easing (cubic ease-in) + + poseFalling(px, pz, elapsed, progress) { + const standing = this.poseStanding(px, pz, elapsed, 0, 0); + const fallen = this.poseFallen(px, pz, elapsed); + + // Cubic ease-in for realistic acceleration + const t = progress * progress * progress; + + // Arm flailing -- sinusoidal perturbation that peaks mid-fall then diminishes + const flailIntensity = Math.sin(progress * Math.PI) * 0.15; + const flailL = Math.sin(elapsed * 8 + progress * 5) * flailIntensity; + const flailR = Math.cos(elapsed * 8 + progress * 5) * flailIntensity; + + // Head snaps back early in the fall + const headSnap = progress < 0.4 ? Math.sin(progress * Math.PI / 0.4) * 0.06 : 0; + + const kps = []; + for (let i = 0; i < 17; i++) { + kps.push([ + standing[i][0] * (1 - t) + fallen[i][0] * t, + standing[i][1] * (1 - t) + fallen[i][1] * t, + standing[i][2] * (1 - t) + fallen[i][2] * t, + ]); + } + + // Apply head snap (tilt backward) + kps[0][1] += headSnap; + kps[1][1] += headSnap * 0.9; + kps[2][1] += headSnap * 0.9; + + // Apply arm flailing + kps[7][0] += flailL; kps[7][2] += flailL * 0.5; // left elbow + kps[8][0] += flailR; kps[8][2] -= flailR * 0.5; // right elbow + kps[9][0] += flailL * 1.5; kps[9][2] += flailL; // left wrist + kps[10][0] += flailR * 1.5; kps[10][2] -= flailR; // right wrist + + return kps; + } + + // ---- Exercising -------------------------------------------------------- + + poseExercising(px, pz, elapsed, exerciseType, exerciseTime) { + const et = exerciseTime || elapsed; + + if (exerciseType === 'squats') { + return this._poseSquats(px, pz, et); + } + return this._poseJumpingJacks(px, pz, et); + } + + // Squats: forward lean, hip hinge, arm counterbalance, depth variation + + _poseSquats(px, pz, et) { + const rawPhase = (Math.sin(et * 2.5) + 1) / 2; // 0=up, 1=down + // Depth variation -- every other rep is shallower + const repIndex = Math.floor(et * 2.5 / Math.PI); + const depthMod = (repIndex % 2 === 0) ? 1.0 : 0.7; + const phase = rawPhase * depthMod; + + const squat = phase * 0.5; + const armFwd = phase * 0.4; + // Forward lean increases with squat depth + const forwardLean = phase * 0.08; + // Hip hinge -- hips push back + const hipBack = phase * 0.12; + + return [ + [px + forwardLean * 0.3, 1.72 - squat, pz + forwardLean], // 0 nose + [px - 0.03 + forwardLean * 0.3, 1.74 - squat, pz - 0.02 + forwardLean], // 1 left eye + [px + 0.03 + forwardLean * 0.3, 1.74 - squat, pz - 0.02 + forwardLean], // 2 right eye + [px - 0.07, 1.72 - squat, pz + forwardLean * 0.8], // 3 left ear + [px + 0.07, 1.72 - squat, pz + forwardLean * 0.8], // 4 right ear + [px - 0.22, 1.48 - squat + forwardLean * 0.2, pz + forwardLean * 0.5], // 5 left shoulder + [px + 0.22, 1.48 - squat + forwardLean * 0.2, pz + forwardLean * 0.5], // 6 right shoulder + [px - 0.22, 1.25 - squat * 0.7, pz + armFwd], // 7 left elbow + [px + 0.22, 1.25 - squat * 0.7, pz + armFwd], // 8 right elbow + [px - 0.22, 1.05 - squat * 0.5, pz + armFwd * 1.5], // 9 left wrist (counterbalance) + [px + 0.22, 1.05 - squat * 0.5, pz + armFwd * 1.5], // 10 right wrist + [px - 0.11, 0.98 - squat * 0.3, pz - hipBack], // 11 left hip (pushed back) + [px + 0.11, 0.98 - squat * 0.3, pz - hipBack], // 12 right hip + [px - 0.15, 0.52 - squat * 0.1, pz + squat * 0.3], // 13 left knee + [px + 0.15, 0.52 - squat * 0.1, pz + squat * 0.3], // 14 right knee + [px - 0.13, 0.04, pz + 0.05], // 15 left ankle + [px + 0.13, 0.04, pz + 0.05], // 16 right ankle + ]; + } + + // Jumping jacks: full arm arc, hip sway, landing impact + + _poseJumpingJacks(px, pz, et) { + const rawPhase = (Math.sin(et * 3) + 1) / 2; // 0=closed, 1=open + const phase = rawPhase; + + // Full arm arc -- from sides to overhead in a smooth arc + const armAngle = phase * Math.PI * 0.85; // 0 to ~153 degrees + const armX = Math.sin(armAngle) * 0.55; // lateral spread + const armY = Math.cos(armAngle) * 0.55; // vertical component + + const legSpread = phase * 0.25; + // Landing impact -- brief compression at bottom of cycle + const impact = Math.max(0, -Math.sin(et * 3)) * 0.03; + const jump = Math.max(0, Math.sin(et * 3)) * 0.06; + // Hip sway at apex + const hipSway = Math.sin(et * 3) * 0.015; + + return [ + [px, 1.72 + jump - impact, pz], // 0 nose + [px - 0.03, 1.74 + jump - impact, pz - 0.02], // 1 left eye + [px + 0.03, 1.74 + jump - impact, pz - 0.02], // 2 right eye + [px - 0.07, 1.72 + jump - impact, pz], // 3 left ear + [px + 0.07, 1.72 + jump - impact, pz], // 4 right ear + [px - 0.22, 1.48 + jump - impact, pz], // 5 left shoulder + [px + 0.22, 1.48 + jump - impact, pz], // 6 right shoulder + [px - 0.22 - armX * 0.6, 1.48 - armY * 0.3 + jump, pz], // 7 left elbow (arc) + [px + 0.22 + armX * 0.6, 1.48 - armY * 0.3 + jump, pz], // 8 right elbow + [px - 0.22 - armX, 1.48 - armY + 0.55 + jump, pz], // 9 left wrist (arc) + [px + 0.22 + armX, 1.48 - armY + 0.55 + jump, pz], // 10 right wrist + [px - 0.11 + hipSway, 0.98 + jump - impact, pz], // 11 left hip + [px + 0.11 + hipSway, 0.98 + jump - impact, pz], // 12 right hip + [px - 0.12 - legSpread, 0.52 + jump * 0.5 - impact * 0.5, pz], // 13 left knee + [px + 0.12 + legSpread, 0.52 + jump * 0.5 - impact * 0.5, pz], // 14 right knee + [px - 0.13 - legSpread * 1.3, 0.04 - impact * 0.3, pz], // 15 left ankle + [px + 0.13 + legSpread * 1.3, 0.04 - impact * 0.3, pz], // 16 right ankle + ]; + } + + // ---- Gesturing --------------------------------------------------------- + + poseGesturing(px, pz, elapsed, gestureType, intensity) { + const base = this.poseStanding(px, pz, elapsed, 0, 0); + if (intensity <= 0) return base; + const gt = elapsed; + + switch (gestureType) { + case 'wave': + return this._gestureWave(base, px, pz, gt, intensity); + case 'swipe_left': + return this._gestureSwipe(base, px, pz, gt, intensity); + case 'circle': + return this._gestureCircle(base, px, pz, gt, intensity); + case 'point': + return this._gesturePoint(base, px, pz, gt, intensity); + default: + return base; + } + } + + // Wave: fluid hand oscillation, elbow pivot, slight shoulder raise + + _gestureWave(base, px, pz, gt, intensity) { + const wave = Math.sin(gt * 6) * 0.15 * intensity; + const waveSmooth = Math.sin(gt * 6 + 0.3) * 0.08 * intensity; // secondary harmonic + const shoulderRaise = 0.04 * intensity; + const elbowPivot = Math.sin(gt * 3) * 0.03 * intensity; + + // Shoulder rises slightly during wave + base[6][1] += shoulderRaise; + // Elbow raised and pivoting + base[8] = [ + px + 0.32 + elbowPivot, + 1.55 * intensity + 1.18 * (1 - intensity) + shoulderRaise, + pz + 0.05, + ]; + // Wrist oscillates fluidly + base[10] = [ + px + 0.32 + wave + waveSmooth * 0.3, + 1.7 * intensity + 0.92 * (1 - intensity) + shoulderRaise, + pz + 0.08 + waveSmooth, + ]; + // Slight body lean away from waving arm + base[0][0] -= 0.01 * intensity; + base[5][0] -= 0.008 * intensity; + return base; + } + + // Swipe: full body rotation follow-through, arm extension + + _gestureSwipe(base, px, pz, gt, intensity) { + const sweep = Math.sin(gt * 2) * intensity; + // Body rotation follows the arm + const bodyRotation = sweep * 0.04; + const shoulderTwist = sweep * 0.025; + + // Upper body rotates + for (let i = 0; i <= 4; i++) base[i][0] += bodyRotation * 0.5; + base[5][0] -= shoulderTwist; + base[6][0] += shoulderTwist; + + // Arm extends fully during swipe + base[8] = [px + 0.15 + sweep * 0.4, 1.3, pz + 0.3]; + base[10] = [px - 0.1 + sweep * 0.6, 1.3, pz + 0.55]; + + // Hip counter-rotation + base[11][0] += bodyRotation * -0.2; + base[12][0] += bodyRotation * -0.2; + return base; + } + + // Circle: smooth circular motion with forearm rotation + + _gestureCircle(base, px, pz, gt, intensity) { + const angle = gt * 2.5; + const radius = 0.25 * intensity; + const cx = Math.cos(angle) * radius; + const cy = Math.sin(angle) * radius; + // Forearm rotation -- wrist traces a smaller secondary circle + const forearmAngle = angle * 1.5; + const forearmR = 0.06 * intensity; + + base[8] = [ + px + 0.3 + cx * 0.5, + 1.3 + cy * 0.5, + pz + 0.2 + Math.sin(angle) * 0.05, + ]; + base[10] = [ + px + 0.3 + cx + Math.cos(forearmAngle) * forearmR, + 1.3 + cy + Math.sin(forearmAngle) * forearmR, + pz + 0.35 + Math.sin(angle) * 0.08, + ]; + // Slight shoulder movement following arm + base[6][0] += cx * 0.08; + base[6][1] += cy * 0.04; + return base; + } + + // Point: extended index finger simulation with arm sway + + _gesturePoint(base, px, pz, gt, intensity) { + const point = intensity; + // Slight arm sway -- breathing/holding still + const sway = Math.sin(gt * 1.5) * 0.01 * intensity; + const vertSway = Math.cos(gt * 1.2) * 0.008 * intensity; + + base[8] = [px + 0.15 + sway, 1.35 + vertSway, pz + 0.35 * point]; + base[10] = [px + 0.08 + sway * 0.5, 1.38 + vertSway * 0.5, pz + 0.70 * point]; + + // Lean slightly toward point direction + base[0][2] += 0.02 * point; + base[5][2] += 0.01 * point; + base[6][2] += 0.01 * point; + return base; + } + + // ---- Crouching --------------------------------------------------------- + // Stealth-crawl option, weight transfer between legs + + poseCrouching(px, pz, elapsed, bp) { + const sway = Math.sin(elapsed * 1.5) * 0.005; + + // Weight transfer between legs (slow rocking) + const weightTransfer = Math.sin(elapsed * 0.8) * 0.025; + const leftDown = Math.max(0, weightTransfer) * 0.03; + const rightDown = Math.max(0, -weightTransfer) * 0.03; + + // Stealth-crawl micro-movement (slow forward creep every ~4s) + const crawlCycle = elapsed % 4.0; + const crawlActive = crawlCycle > 3.0; + const crawlAmt = crawlActive ? Math.sin((crawlCycle - 3.0) * Math.PI) * 0.02 : 0; + + // Arms adjust for balance during weight transfer + const armBalance = weightTransfer * 0.3; + + return [ + [px + sway, 1.05 + bp, pz + 0.15 + crawlAmt], // 0 nose + [px - 0.03, 1.07 + bp, pz + 0.13 + crawlAmt], // 1 left eye + [px + 0.03, 1.07 + bp, pz + 0.13 + crawlAmt], // 2 right eye + [px - 0.07, 1.05 + bp, pz + 0.12 + crawlAmt], // 3 left ear + [px + 0.07, 1.05 + bp, pz + 0.12 + crawlAmt], // 4 right ear + [px - 0.22, 0.88 + bp, pz + 0.05], // 5 left shoulder + [px + 0.22, 0.88 + bp, pz + 0.05], // 6 right shoulder + [px - 0.28 - armBalance, 0.65 + bp, pz + 0.15 + crawlAmt * 0.5], // 7 left elbow + [px + 0.28 + armBalance, 0.65 + bp, pz + 0.15 + crawlAmt * 0.5], // 8 right elbow + [px - 0.22 - armBalance * 0.5, 0.48, pz + 0.2 + crawlAmt], // 9 left wrist + [px + 0.22 + armBalance * 0.5, 0.48, pz + 0.2 + crawlAmt], // 10 right wrist + [px - 0.12 + weightTransfer, 0.42, pz - 0.05], // 11 left hip + [px + 0.12 + weightTransfer, 0.42, pz - 0.05], // 12 right hip + [px - 0.15 + weightTransfer * 0.5, 0.35 - leftDown, pz + 0.25], // 13 left knee + [px + 0.15 + weightTransfer * 0.5, 0.35 - rightDown, pz + 0.25], // 14 right knee + [px - 0.13, 0.04, pz + 0.1], // 15 left ankle + [px + 0.13, 0.04, pz + 0.1], // 16 right ankle + ]; + } +} diff --git a/observatory/js/post-processing.js b/observatory/js/post-processing.js new file mode 100644 index 00000000..4ad33116 --- /dev/null +++ b/observatory/js/post-processing.js @@ -0,0 +1,125 @@ +/** + * Post-Processing — Subtle bloom for green glow wireframe, + * warm vignette, minimal grain. Foundation-style. + */ +import * as THREE from 'three'; +import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; +import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; +import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; +import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js'; + +const VignetteShader = { + uniforms: { + tDiffuse: { value: null }, + uTime: { value: 0 }, + uVignetteStrength: { value: 0.5 }, + uChromaticStrength: { value: 0.0015 }, + uGrainStrength: { value: 0.03 }, + uWarmth: { value: 0.08 }, + }, + vertexShader: ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + uniform sampler2D tDiffuse; + uniform float uTime; + uniform float uVignetteStrength; + uniform float uChromaticStrength; + uniform float uGrainStrength; + uniform float uWarmth; + varying vec2 vUv; + + float rand(vec2 co) { + return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453); + } + + void main() { + vec2 uv = vUv; + vec2 center = uv - 0.5; + float dist = length(center); + + // Subtle chromatic aberration at edges only + vec2 offset = center * dist * uChromaticStrength; + float r = texture2D(tDiffuse, uv + offset).r; + float g = texture2D(tDiffuse, uv).g; + float b = texture2D(tDiffuse, uv - offset * 0.5).b; + vec3 color = vec3(r, g, b); + + // Warm vignette + float vignette = 1.0 - dist * dist * uVignetteStrength * 1.8; + color *= vignette; + + // Very subtle warm shift in shadows + float luma = dot(color, vec3(0.299, 0.587, 0.114)); + color.r += (1.0 - luma) * uWarmth * 0.5; + color.g += (1.0 - luma) * uWarmth * 0.2; + + // Minimal grain + float grain = (rand(uv * uTime * 0.01) - 0.5) * uGrainStrength; + color += grain; + + gl_FragColor = vec4(color, 1.0); + } + `, +}; + +export class PostProcessing { + constructor(renderer, scene, camera) { + const size = renderer.getSize(new THREE.Vector2()); + + this.composer = new EffectComposer(renderer); + this.composer.addPass(new RenderPass(scene, camera)); + + // Bloom — tuned for green wireframe glow + this._bloomPass = new UnrealBloomPass( + new THREE.Vector2(size.x, size.y), + 1.0, // strength (less aggressive than before) + 0.5, // radius + 0.25 // 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/presence-cartography.js b/observatory/js/presence-cartography.js new file mode 100644 index 00000000..37c6e339 --- /dev/null +++ b/observatory/js/presence-cartography.js @@ -0,0 +1,178 @@ +/** + * 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/scenario-props.js b/observatory/js/scenario-props.js new file mode 100644 index 00000000..b0a1d1bf --- /dev/null +++ b/observatory/js/scenario-props.js @@ -0,0 +1,739 @@ +/** + * ScenarioProps — Scenario-specific room furniture and props + * + * Extracted from main.js. Builds and manages visibility of all physical + * objects that appear/disappear based on the active scenario: bed, chair, + * exercise mat, door, rubble wall, screen/TV, desks, security cameras, + * and the alert light system. + */ +import * as THREE from 'three'; + +// Scenario-to-prop-name mapping +const SCENARIO_PROPS = { + empty_room: [], + single_breathing: [], + two_walking: [], + fall_event: [], + sleep_monitoring: ['bed'], + intrusion_detect: ['door'], + gesture_control: ['screen'], + crowd_occupancy: ['desk', 'desk2'], + search_rescue: ['rubbleWall'], + elderly_care: ['chair'], + fitness_tracking: ['exerciseMat'], + security_patrol: ['camera1', 'camera2'], +}; + +export class ScenarioProps { + constructor(scene) { + this._scene = scene; + this._props = {}; + this._currentScenario = null; + this._alertLight = null; + this._alertIntensity = 0; + + // Animatable references + this._screenGlow = null; + this._camera1Group = null; + this._camera2Group = null; + this._cam1Cone = null; + this._cam2Cone = null; + this._cam1Led = null; + this._cam2Led = null; + this._dustParticles = null; + this._doorSpotlight = null; + this._alarmHousing = null; + this._powerLed = null; + + this._build(); + } + + // ---- helper: positioned box with shadow ---- + _box(x, y, z, w, h, d, mat) { + const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat); + m.position.set(x, y, z); + m.castShadow = true; + m.receiveShadow = true; + return m; + } + + // ---- helper: positioned cylinder with shadow ---- + _cyl(x, y, z, rTop, rBot, h, segs, mat) { + const m = new THREE.Mesh(new THREE.CylinderGeometry(rTop, rBot, h, segs), mat); + m.position.set(x, y, z); + m.castShadow = true; + m.receiveShadow = true; + return m; + } + + // ======================================== + // BUILD ALL PROPS + // ======================================== + + _build() { + const darkMat = new THREE.MeshStandardMaterial({ color: 0x6b5840, roughness: 0.6, emissive: 0x1a1408, emissiveIntensity: 0.25 }); + const metalMat = new THREE.MeshStandardMaterial({ color: 0x808088, roughness: 0.3, metalness: 0.7, emissive: 0x1a1a20, emissiveIntensity: 0.2 }); + const accentMat = new THREE.MeshStandardMaterial({ color: 0x606070, roughness: 0.4, metalness: 0.4, emissive: 0x101018, emissiveIntensity: 0.15 }); + + this._buildBed(darkMat); + this._buildChair(darkMat, accentMat); + this._buildExerciseMat(); + this._buildDoor(); + this._buildRubbleWall(); + this._buildScreen(metalMat); + this._buildDesks(darkMat, metalMat, accentMat); + this._buildCameras(metalMat); + this._buildAlertSystem(); + } + + // ---- BED (sleep monitoring) ---- + _buildBed(darkMat) { + const bedGroup = new THREE.Group(); + + // Bed frame with legs + const frameMat = new THREE.MeshStandardMaterial({ color: 0x7a6448, roughness: 0.55, metalness: 0.25, emissive: 0x181008, emissiveIntensity: 0.25 }); + const bedFrame = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.12, 1.2), frameMat); + bedFrame.position.set(3.5, 0.32, -3.5); + bedFrame.castShadow = true; + bedGroup.add(bedFrame); + + // Frame legs (4 short posts) + for (const [lx, lz] of [[2.5, -4.0], [4.5, -4.0], [2.5, -3.0], [4.5, -3.0]]) { + bedGroup.add(this._cyl(lx, 0.13, lz, 0.04, 0.04, 0.26, 6, frameMat)); + } + + // Headboard — tall panel at head of bed + const headboardMat = new THREE.MeshStandardMaterial({ color: 0x6a5440, roughness: 0.65, emissive: 0x140e08, emissiveIntensity: 0.2 }); + const headboard = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.7, 1.2), headboardMat); + headboard.position.set(2.38, 0.65, -3.5); + headboard.castShadow = true; + bedGroup.add(headboard); + + // Mattress + const mattressMat = new THREE.MeshStandardMaterial({ color: 0x484860, roughness: 0.75, emissive: 0x0c0c1a, emissiveIntensity: 0.2 }); + const mattress = new THREE.Mesh(new THREE.BoxGeometry(2.0, 0.15, 1.1), mattressMat); + mattress.position.set(3.5, 0.455, -3.5); + mattress.castShadow = true; + bedGroup.add(mattress); + + // Wrinkled sheet — wave-displaced plane + const sheetGeo = new THREE.PlaneGeometry(1.4, 1.0, 20, 20); + const posAttr = sheetGeo.getAttribute('position'); + for (let i = 0; i < posAttr.count; i++) { + const px = posAttr.getX(i); + const py = posAttr.getY(i); + posAttr.setZ(i, Math.sin(px * 4) * 0.015 + Math.cos(py * 5) * 0.01 + Math.sin(px * py * 3) * 0.008); + } + posAttr.needsUpdate = true; + sheetGeo.computeVertexNormals(); + const sheetMat = new THREE.MeshStandardMaterial({ + color: 0x506880, roughness: 0.75, side: THREE.DoubleSide, emissive: 0x0c1018, emissiveIntensity: 0.2, + }); + const sheet = new THREE.Mesh(sheetGeo, sheetMat); + sheet.rotation.x = -Math.PI / 2; + sheet.position.set(3.7, 0.54, -3.5); + sheet.castShadow = true; + bedGroup.add(sheet); + + // Pillow — soft shape using scaled sphere + const pillowGeo = new THREE.SphereGeometry(0.18, 12, 8); + pillowGeo.scale(1, 0.35, 1.4); + const pillowMat = new THREE.MeshStandardMaterial({ color: 0x706868, roughness: 0.7, emissive: 0x141010, emissiveIntensity: 0.2 }); + const pillow = new THREE.Mesh(pillowGeo, pillowMat); + pillow.position.set(2.65, 0.52, -3.5); + pillow.castShadow = true; + bedGroup.add(pillow); + + // Bedside lamp — small cylinder + sphere shade on a tiny table + const lampBaseMat = new THREE.MeshStandardMaterial({ color: 0x686870, roughness: 0.3, metalness: 0.7, emissive: 0x101018, emissiveIntensity: 0.15 }); + // Nightstand + bedGroup.add(this._box(2.15, 0.25, -3.5, 0.35, 0.5, 0.35, darkMat)); + // Lamp base + bedGroup.add(this._cyl(2.15, 0.55, -3.5, 0.04, 0.05, 0.1, 8, lampBaseMat)); + // Lamp stem + bedGroup.add(this._cyl(2.15, 0.68, -3.5, 0.015, 0.015, 0.2, 6, lampBaseMat)); + // Lamp shade (emissive warm glow) + const shadeMat = new THREE.MeshStandardMaterial({ + color: 0x705830, emissive: 0x604018, emissiveIntensity: 1.0, roughness: 0.6, + side: THREE.DoubleSide, transparent: true, opacity: 0.9, + }); + const shade = new THREE.Mesh(new THREE.ConeGeometry(0.08, 0.1, 8, 1, true), shadeMat); + shade.position.set(2.15, 0.78, -3.5); + shade.rotation.x = Math.PI; + bedGroup.add(shade); + + // Warm lamp light + const lampLight = new THREE.PointLight(0xffcc88, 2.0, 6, 1.2); + lampLight.position.set(2.15, 0.78, -3.5); + bedGroup.add(lampLight); + + this._props.bed = bedGroup; + bedGroup.visible = false; + this._scene.add(bedGroup); + } + + // ---- CHAIR (elderly care) ---- + _buildChair(darkMat, accentMat) { + const chairGroup = new THREE.Group(); + chairGroup.position.set(1, 0, -1.5); + + const cushionMat = new THREE.MeshStandardMaterial({ color: 0x5a5078, roughness: 0.7, emissive: 0x10101a, emissiveIntensity: 0.2 }); + + // Seat + chairGroup.add(this._box(0, 0.45, 0, 0.5, 0.04, 0.45, darkMat)); + // Seat cushion — slightly puffy + const cushionGeo = new THREE.BoxGeometry(0.46, 0.06, 0.42); + // Gentle puff on top vertices + const cPos = cushionGeo.getAttribute('position'); + for (let i = 0; i < cPos.count; i++) { + if (cPos.getY(i) > 0) { + const dx = cPos.getX(i) / 0.23; + const dz = cPos.getZ(i) / 0.21; + cPos.setY(i, cPos.getY(i) + 0.015 * (1 - dx * dx) * (1 - dz * dz)); + } + } + cPos.needsUpdate = true; + cushionGeo.computeVertexNormals(); + const cushion = new THREE.Mesh(cushionGeo, cushionMat); + cushion.position.set(0, 0.50, 0); + cushion.castShadow = true; + chairGroup.add(cushion); + + // Back + chairGroup.add(this._box(0, 0.72, -0.22, 0.5, 0.5, 0.04, darkMat)); + // Legs + for (const [lx, lz] of [[-0.22, -0.2], [0.22, -0.2], [-0.22, 0.2], [0.22, 0.2]]) { + chairGroup.add(this._box(lx, 0.22, lz, 0.04, 0.44, 0.04, darkMat)); + } + // Armrests + chairGroup.add(this._box(-0.28, 0.6, 0, 0.04, 0.04, 0.4, accentMat)); + chairGroup.add(this._box(0.28, 0.6, 0, 0.04, 0.04, 0.4, accentMat)); + // Armrest supports + chairGroup.add(this._box(-0.28, 0.52, -0.18, 0.04, 0.12, 0.04, accentMat)); + chairGroup.add(this._box(0.28, 0.52, -0.18, 0.04, 0.12, 0.04, accentMat)); + + // Small side table + const tableMat = new THREE.MeshStandardMaterial({ color: 0x685840, roughness: 0.55, emissive: 0x14100a, emissiveIntensity: 0.2 }); + chairGroup.add(this._box(0.65, 0.3, 0, 0.35, 0.03, 0.35, tableMat)); + // Table legs + for (const [tx, tz] of [[0.5, -0.14], [0.8, -0.14], [0.5, 0.14], [0.8, 0.14]]) { + chairGroup.add(this._cyl(tx, 0.15, tz, 0.015, 0.015, 0.28, 6, tableMat)); + } + + this._props.chair = chairGroup; + chairGroup.visible = false; + this._scene.add(chairGroup); + } + + // ---- EXERCISE MAT (fitness tracking) ---- + _buildExerciseMat() { + const matGroup = new THREE.Group(); + const matMat = new THREE.MeshStandardMaterial({ color: 0x408858, roughness: 0.75, emissive: 0x0c2010, emissiveIntensity: 0.25 }); + + // Mat body + const exerciseMat = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.015, 0.8), matMat); + exerciseMat.position.set(0, 0.008, 0); + exerciseMat.receiveShadow = true; + matGroup.add(exerciseMat); + + // Boundary lines on the mat (thin strips) + const lineMat = new THREE.MeshStandardMaterial({ color: 0x50a068, roughness: 0.7, emissive: 0x102818, emissiveIntensity: 0.3 }); + // Longitudinal borders + matGroup.add(this._box(0, 0.017, -0.37, 1.7, 0.003, 0.02, lineMat)); + matGroup.add(this._box(0, 0.017, 0.37, 1.7, 0.003, 0.02, lineMat)); + // Cross lines (exercise area markers) + for (const xOff of [-0.6, 0, 0.6]) { + matGroup.add(this._box(xOff, 0.017, 0, 0.02, 0.003, 0.74, lineMat)); + } + + // Water bottle (cylinder body + hemisphere cap) + const bottleMat = new THREE.MeshStandardMaterial({ color: 0x4878a8, roughness: 0.2, metalness: 0.7, emissive: 0x0c1828, emissiveIntensity: 0.25 }); + const bottleBody = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.18, 10), bottleMat); + bottleBody.position.set(1.1, 0.09, 0.25); + bottleBody.castShadow = true; + matGroup.add(bottleBody); + const bottleCap = new THREE.Mesh(new THREE.SphereGeometry(0.035, 8, 6, 0, Math.PI * 2, 0, Math.PI / 2), bottleMat); + bottleCap.position.set(1.1, 0.18, 0.25); + matGroup.add(bottleCap); + // Bottle neck + const neckMat = new THREE.MeshStandardMaterial({ color: 0x587088, roughness: 0.3, metalness: 0.6, emissive: 0x0c1420, emissiveIntensity: 0.2 }); + matGroup.add(this._cyl(1.1, 0.21, 0.25, 0.018, 0.025, 0.04, 8, neckMat)); + + // Small towel (flat draped box) + const towelMat = new THREE.MeshStandardMaterial({ color: 0x686890, roughness: 0.75, emissive: 0x101020, emissiveIntensity: 0.2 }); + const towel = this._box(1.1, 0.01, -0.25, 0.3, 0.008, 0.15, towelMat); + towel.rotation.y = 0.15; + matGroup.add(towel); + + this._props.exerciseMat = matGroup; + matGroup.visible = false; + this._scene.add(matGroup); + } + + // ---- DOOR (intrusion detection) ---- + _buildDoor() { + const doorGroup = new THREE.Group(); + doorGroup.position.set(-5.5, 0, -1); + const doorMat = new THREE.MeshStandardMaterial({ color: 0x7a6040, roughness: 0.5, emissive: 0x18140a, emissiveIntensity: 0.25 }); + const hingeMat = new THREE.MeshStandardMaterial({ color: 0x909098, roughness: 0.2, metalness: 0.85, emissive: 0x181820, emissiveIntensity: 0.15 }); + + // Left jamb + doorGroup.add(this._box(-0.45, 1.1, 0, 0.08, 2.2, 0.15, doorMat)); + // Right jamb + doorGroup.add(this._box(0.45, 1.1, 0, 0.08, 2.2, 0.15, doorMat)); + // Top + doorGroup.add(this._box(0, 2.2, 0, 0.98, 0.08, 0.15, doorMat)); + // Door panel (partially open) + const doorPanel = new THREE.Mesh(new THREE.BoxGeometry(0.85, 2.1, 0.04), doorMat); + doorPanel.position.set(0.2, 1.05, -0.2); + doorPanel.rotation.y = -0.7; + doorPanel.castShadow = true; + doorGroup.add(doorPanel); + + // Door handle (torus) + const handleMat = new THREE.MeshStandardMaterial({ color: 0xaaaaB0, roughness: 0.1, metalness: 0.9, emissive: 0x1a1a20, emissiveIntensity: 0.2 }); + const handle = new THREE.Mesh(new THREE.TorusGeometry(0.035, 0.008, 6, 12), handleMat); + // Position on the door panel (relative to panel pivot) + handle.position.set(0.48, 1.05, -0.22); + handle.rotation.y = -0.7; + handle.rotation.x = Math.PI / 2; + doorGroup.add(handle); + + // Hinge details — small cylinders at jamb + for (const hy of [0.4, 1.1, 1.8]) { + const hinge = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.015, 0.06, 6), hingeMat); + hinge.position.set(-0.42, hy, 0.06); + doorGroup.add(hinge); + } + + // Light spill through the gap — spotlight from outside + const doorSpot = new THREE.SpotLight(0x88aacc, 3.0, 10, Math.PI / 4, 0.3, 0.6); + doorSpot.position.set(-0.8, 1.2, -0.5); + doorSpot.target.position.set(0.5, 0, 0.5); + doorGroup.add(doorSpot); + doorGroup.add(doorSpot.target); + this._doorSpotlight = doorSpot; + + // Window next to door — simple frame with translucent pane + const windowFrame = new THREE.MeshStandardMaterial({ color: 0x686878, roughness: 0.35, metalness: 0.6, emissive: 0x101018, emissiveIntensity: 0.15 }); + // Frame + doorGroup.add(this._box(1.2, 1.5, 0, 0.04, 0.8, 0.06, windowFrame)); + doorGroup.add(this._box(1.2, 1.5, 0, 0.6, 0.04, 0.06, windowFrame)); + doorGroup.add(this._box(0.92, 1.5, 0, 0.04, 0.8, 0.06, windowFrame)); + doorGroup.add(this._box(1.48, 1.5, 0, 0.04, 0.8, 0.06, windowFrame)); + doorGroup.add(this._box(1.2, 1.1, 0, 0.6, 0.04, 0.06, windowFrame)); + doorGroup.add(this._box(1.2, 1.9, 0, 0.6, 0.04, 0.06, windowFrame)); + // Glass pane + const glassMat = new THREE.MeshStandardMaterial({ + color: 0x305880, transparent: true, opacity: 0.4, roughness: 0.05, metalness: 0.3, emissive: 0x0c1830, emissiveIntensity: 0.35, + }); + const glass = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.72, 0.01), glassMat); + glass.position.set(1.2, 1.5, 0); + doorGroup.add(glass); + + this._props.door = doorGroup; + doorGroup.visible = false; + this._scene.add(doorGroup); + } + + // ---- RUBBLE WALL (search & rescue) ---- + _buildRubbleWall() { + const rubbleGroup = new THREE.Group(); + const rubbleMat = new THREE.MeshStandardMaterial({ color: 0x807868, roughness: 0.75, emissive: 0x181610, emissiveIntensity: 0.25 }); + const rebarMat = new THREE.MeshStandardMaterial({ color: 0x8a7858, roughness: 0.4, metalness: 0.7, emissive: 0x1a1408, emissiveIntensity: 0.2 }); + + // Broken wall — main slab + rubbleGroup.add(this._box(2, 1, 0, 0.4, 2, 3, rubbleMat)); + + // Wall crack lines (thin dark boxes embedded in wall surface) + const crackMat = new THREE.MeshStandardMaterial({ color: 0x403828, roughness: 0.9 }); + const cracks = [ + [1.82, 1.4, -0.3, 0.01, 0.6, 0.02, 0.3], + [1.82, 0.8, 0.5, 0.01, 0.5, 0.02, -0.2], + [1.82, 1.6, 0.8, 0.01, 0.4, 0.02, 0.15], + [1.82, 0.5, -0.7, 0.01, 0.35, 0.02, -0.25], + ]; + for (const [cx, cy, cz, cw, ch, cd, rot] of cracks) { + const crack = this._box(cx, cy, cz, cw, ch, cd, crackMat); + crack.rotation.z = rot; + rubbleGroup.add(crack); + } + + // Rebar — thin metal cylinders protruding from the wall + for (const [rx, ry, rz, rLen, rRot] of [ + [1.6, 1.7, -0.4, 0.8, 0.3], + [1.5, 1.2, 0.6, 0.6, -0.2], + [1.7, 0.9, -0.8, 0.5, 0.5], + [1.55, 1.5, 1.0, 0.7, -0.4], + ]) { + const rebar = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, rLen, 6), rebarMat); + rebar.position.set(rx, ry, rz); + rebar.rotation.z = Math.PI / 2 + rRot; + rebar.rotation.y = rRot * 0.5; + rebar.castShadow = true; + rubbleGroup.add(rebar); + } + + // Rubble pieces — more varied with random rotations + const rubbleColors = [0x807868, 0x706860, 0x908878, 0x686058]; + for (let i = 0; i < 10; i++) { + const s = 0.12 + Math.random() * 0.3; + const rMat = new THREE.MeshStandardMaterial({ + color: rubbleColors[i % rubbleColors.length], roughness: 0.7 + Math.random() * 0.15, + emissive: 0x141210, emissiveIntensity: 0.2, + }); + const piece = this._box( + 1.3 + Math.random() * 1.4, s / 2, -1.5 + Math.random() * 3, + s, s * (0.4 + Math.random() * 0.5), s * (0.6 + Math.random() * 0.4), rMat + ); + piece.rotation.x = (Math.random() - 0.5) * 0.6; + piece.rotation.y = (Math.random() - 0.5) * 1.2; + piece.rotation.z = (Math.random() - 0.5) * 0.4; + rubbleGroup.add(piece); + } + + // Dust particles near rubble + const dustCount = 60; + const dustGeo = new THREE.BufferGeometry(); + const dustPositions = new Float32Array(dustCount * 3); + for (let i = 0; i < dustCount; i++) { + dustPositions[i * 3] = 1.0 + Math.random() * 2.0; + dustPositions[i * 3 + 1] = Math.random() * 2.5; + dustPositions[i * 3 + 2] = -1.5 + Math.random() * 3.0; + } + dustGeo.setAttribute('position', new THREE.BufferAttribute(dustPositions, 3)); + const dustMaterial = new THREE.PointsMaterial({ + color: 0xaa9988, size: 0.03, transparent: true, opacity: 0.5, + blending: THREE.AdditiveBlending, depthWrite: false, + }); + this._dustParticles = new THREE.Points(dustGeo, dustMaterial); + rubbleGroup.add(this._dustParticles); + + this._props.rubbleWall = rubbleGroup; + rubbleGroup.visible = false; + this._scene.add(rubbleGroup); + } + + // ---- SCREEN / TV (gesture control) ---- + _buildScreen(metalMat) { + const screenGroup = new THREE.Group(); + const screenFrame = new THREE.MeshStandardMaterial({ color: 0x484850, roughness: 0.2, metalness: 0.7, emissive: 0x0c0c14, emissiveIntensity: 0.15 }); + + // Frame + screenGroup.add(this._box(0, 1.5, -4.7, 1.8, 1.1, 0.06, screenFrame)); + // Screen surface (emissive, color shifts in update()) + const screenSurfMat = new THREE.MeshStandardMaterial({ + color: 0x1a3868, emissive: 0x1a3868, emissiveIntensity: 1.2, roughness: 0.1, + }); + const screenSurf = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.9, 0.02), screenSurfMat); + screenSurf.position.set(0, 1.5, -4.66); + screenGroup.add(screenSurf); + this._screenGlow = screenSurfMat; + + // Stand / mount — neck + base + screenGroup.add(this._box(0, 0.88, -4.7, 0.08, 0.16, 0.08, screenFrame)); + screenGroup.add(this._box(0, 0.78, -4.7, 0.4, 0.03, 0.2, metalMat)); + + // Power LED indicator + const ledMat = new THREE.MeshStandardMaterial({ + color: 0x00ff40, emissive: 0x00ff40, emissiveIntensity: 1.0, + }); + const powerLed = new THREE.Mesh(new THREE.SphereGeometry(0.012, 6, 4), ledMat); + powerLed.position.set(0.82, 0.96, -4.66); + screenGroup.add(powerLed); + this._powerLed = ledMat; + + // Subtle screen glow (point light) + const screenLight = new THREE.PointLight(0x4080e0, 1.5, 6); + screenLight.position.set(0, 1.5, -4.5); + screenGroup.add(screenLight); + + // Media console below the screen + const consoleMat = new THREE.MeshStandardMaterial({ color: 0x484858, roughness: 0.45, metalness: 0.5, emissive: 0x0c0c14, emissiveIntensity: 0.15 }); + screenGroup.add(this._box(0, 0.55, -4.7, 1.2, 0.35, 0.35, consoleMat)); + // Console shelf divider + screenGroup.add(this._box(0, 0.55, -4.54, 1.1, 0.02, 0.01, metalMat)); + + this._props.screen = screenGroup; + screenGroup.visible = false; + this._scene.add(screenGroup); + } + + // ---- DESKS (crowd / office) ---- + _buildDesks(darkMat, metalMat, accentMat) { + // Desk 1 (left) + const deskGroup = new THREE.Group(); + deskGroup.add(this._box(-2, 0.38, -1, 1.2, 0.04, 0.6, darkMat)); + for (const [lx, lz] of [[-2.55, -1.25], [-1.45, -1.25], [-2.55, -0.75], [-1.45, -0.75]]) { + deskGroup.add(this._box(lx, 0.19, lz, 0.04, 0.38, 0.04, darkMat)); + } + // Monitor on desk 1 + const monitorMat = new THREE.MeshStandardMaterial({ color: 0x484850, roughness: 0.2, metalness: 0.7, emissive: 0x0c0c14, emissiveIntensity: 0.15 }); + const monScreenMat = new THREE.MeshStandardMaterial({ + color: 0x183858, emissive: 0x183858, emissiveIntensity: 1.0, roughness: 0.1, + }); + deskGroup.add(this._box(-2, 0.62, -1.15, 0.5, 0.35, 0.03, monitorMat)); + deskGroup.add(this._box(-2, 0.62, -1.13, 0.44, 0.29, 0.01, monScreenMat)); + deskGroup.add(this._box(-2, 0.42, -1.1, 0.06, 0.04, 0.06, metalMat)); // stand neck + deskGroup.add(this._box(-2, 0.40, -1.05, 0.18, 0.01, 0.12, metalMat)); // stand base + // Keyboard outline + deskGroup.add(this._box(-2, 0.405, -0.85, 0.35, 0.008, 0.12, accentMat)); + // Office chair at desk 1 + this._buildOfficeChair(deskGroup, -2, -0.55, darkMat, metalMat); + + // Monitor glow light + const monLight = new THREE.PointLight(0x4080e0, 1.2, 4); + monLight.position.set(-2, 0.7, -1.0); + deskGroup.add(monLight); + + this._props.desk = deskGroup; + deskGroup.visible = false; + this._scene.add(deskGroup); + + // Desk 2 (right) + const desk2Group = new THREE.Group(); + desk2Group.add(this._box(2, 0.38, 1, 1.0, 0.04, 0.6, darkMat)); + for (const [lx, lz] of [[1.45, 0.75], [2.55, 0.75], [1.45, 1.25], [2.55, 1.25]]) { + desk2Group.add(this._box(lx, 0.19, lz, 0.04, 0.38, 0.04, darkMat)); + } + // Monitor on desk 2 + desk2Group.add(this._box(2, 0.62, 1.15, 0.5, 0.35, 0.03, monitorMat)); + desk2Group.add(this._box(2, 0.62, 1.17, 0.44, 0.29, 0.01, monScreenMat)); + desk2Group.add(this._box(2, 0.42, 1.1, 0.06, 0.04, 0.06, metalMat)); + desk2Group.add(this._box(2, 0.40, 1.05, 0.18, 0.01, 0.12, metalMat)); + // Keyboard + desk2Group.add(this._box(2, 0.405, 0.85, 0.35, 0.008, 0.12, accentMat)); + // Office chair at desk 2 + this._buildOfficeChair(desk2Group, 2, 0.55, darkMat, metalMat); + + // Water cooler / plant between desks area + const plantMat = new THREE.MeshStandardMaterial({ color: 0x2a7838, roughness: 0.7, emissive: 0x0c2810, emissiveIntensity: 0.3 }); + const potMat = new THREE.MeshStandardMaterial({ color: 0x706858, roughness: 0.6, emissive: 0x14120c, emissiveIntensity: 0.15 }); + desk2Group.add(this._cyl(3.2, 0.15, 0, 0.12, 0.1, 0.3, 8, potMat)); + // Foliage — cluster of small spheres + for (const [fx, fy, fz] of [[3.2, 0.45, 0], [3.15, 0.4, 0.06], [3.25, 0.42, -0.05]]) { + const leaf = new THREE.Mesh(new THREE.SphereGeometry(0.08, 6, 5), plantMat); + leaf.position.set(fx, fy, fz); + desk2Group.add(leaf); + } + + // Monitor glow light + const monLight2 = new THREE.PointLight(0x4080e0, 1.2, 4); + monLight2.position.set(2, 0.7, 1.0); + desk2Group.add(monLight2); + + this._props.desk2 = desk2Group; + desk2Group.visible = false; + this._scene.add(desk2Group); + } + + // Helper: small office chair + _buildOfficeChair(parent, x, z, darkMat, metalMat) { + // Seat + parent.add(this._box(x, 0.38, z, 0.35, 0.03, 0.35, darkMat)); + // Backrest + parent.add(this._box(x, 0.55, z - 0.16, 0.32, 0.3, 0.03, darkMat)); + // Central post + parent.add(this._cyl(x, 0.22, z, 0.025, 0.025, 0.28, 6, metalMat)); + // Base star (5 legs) + for (let i = 0; i < 5; i++) { + const angle = (i / 5) * Math.PI * 2; + const legLen = 0.16; + const leg = this._box( + x + Math.cos(angle) * legLen * 0.5, 0.04, z + Math.sin(angle) * legLen * 0.5, + legLen, 0.015, 0.025, metalMat + ); + leg.rotation.y = -angle; + parent.add(leg); + } + } + + // ---- SECURITY CAMERAS (patrol) ---- + _buildCameras(metalMat) { + const camData = [ + ['camera1', [5, 3.5, -4.5]], + ['camera2', [-5, 3.5, 4.5]], + ]; + + for (const [name, pos] of camData) { + const camGroup = new THREE.Group(); + camGroup.position.set(...pos); + + // Camera body + camGroup.add(this._box(0, 0, 0, 0.15, 0.1, 0.2, metalMat)); + + // Lens + const lens = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.08, 8), metalMat); + lens.rotation.x = Math.PI / 2; + lens.position.z = 0.14; + camGroup.add(lens); + + // Bracket / mount arm + camGroup.add(this._box(0, 0.1, -0.08, 0.04, 0.2, 0.04, metalMat)); + + // Rotating motor housing (visible joint) + const motorMat = new THREE.MeshStandardMaterial({ color: 0x686870, roughness: 0.35, metalness: 0.8, emissive: 0x141418, emissiveIntensity: 0.15 }); + const motor = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.04, 8), motorMat); + motor.position.set(0, 0.05, -0.08); + camGroup.add(motor); + + // FOV cone (semi-transparent) + const coneMat = new THREE.MeshStandardMaterial({ + color: 0xff3040, transparent: true, opacity: 0.15, + side: THREE.DoubleSide, depthWrite: false, + emissive: 0xff2020, emissiveIntensity: 0.3, + }); + const cone = new THREE.Mesh(new THREE.ConeGeometry(1.5, 3, 16, 1, true), coneMat); + cone.rotation.x = Math.PI / 2; + cone.position.z = 1.7; + camGroup.add(cone); + + // Status LED (blinks in update) + const ledMat = new THREE.MeshStandardMaterial({ + color: 0xff2020, emissive: 0xff2020, emissiveIntensity: 1.0, + }); + const led = new THREE.Mesh(new THREE.SphereGeometry(0.015, 6, 4), ledMat); + led.position.set(0.08, 0.04, 0.08); + camGroup.add(led); + + this._props[name] = camGroup; + camGroup.visible = false; + this._scene.add(camGroup); + + // Store references for animation + if (name === 'camera1') { + this._camera1Group = camGroup; + this._cam1Cone = cone; + this._cam1Led = ledMat; + } else { + this._camera2Group = camGroup; + this._cam2Cone = cone; + this._cam2Led = ledMat; + } + } + } + + // ---- ALERT SYSTEM ---- + _buildAlertSystem() { + // Main alert point light + this._alertLight = new THREE.PointLight(0xff3040, 0, 10); + this._alertLight.position.set(0, 3.5, 0); + this._scene.add(this._alertLight); + + // Ceiling-mounted alarm housing + const housingMat = new THREE.MeshStandardMaterial({ color: 0x686878, roughness: 0.35, metalness: 0.6, emissive: 0x101018, emissiveIntensity: 0.15 }); + const housing = new THREE.Group(); + // Base plate + housing.add(this._box(0, 3.95, 0, 0.2, 0.02, 0.2, housingMat)); + // Housing body + housing.add(this._cyl(0, 3.85, 0, 0.08, 0.1, 0.16, 8, housingMat)); + // Alarm lens (red when active, dark when inactive) + const lensMat = new THREE.MeshStandardMaterial({ + color: 0x330808, emissive: 0x000000, emissiveIntensity: 0, roughness: 0.2, + transparent: true, opacity: 0.8, + }); + const alarmLens = new THREE.Mesh(new THREE.SphereGeometry(0.06, 10, 8, 0, Math.PI * 2, 0, Math.PI / 2), lensMat); + alarmLens.position.set(0, 3.76, 0); + alarmLens.rotation.x = Math.PI; + housing.add(alarmLens); + + this._alarmHousing = housing; + this._alarmLensMat = lensMat; + this._scene.add(housing); + } + + // ======================================== + // UPDATE (called every frame) + // ======================================== + + update(data, currentScenario) { + const scenario = data?.scenario || currentScenario; + const elapsed = Date.now() * 0.001; + + // Switch visible props when scenario changes + if (scenario !== this._currentScenario) { + this._currentScenario = scenario; + for (const prop of Object.values(this._props)) prop.visible = false; + const propsToShow = SCENARIO_PROPS[scenario] || []; + for (const name of propsToShow) { + if (this._props[name]) this._props[name].visible = true; + } + } + + // --- Alert light (fall / intrusion) --- + const cls = data?.classification || {}; + if (cls.fall_detected || cls.intrusion) { + this._alertIntensity = Math.min(2, this._alertIntensity + 0.1); + } else { + this._alertIntensity = Math.max(0, this._alertIntensity - 0.05); + } + // Sawtooth pattern for urgency instead of smooth sine + const alertPhase = (elapsed * 3) % 1.0; + const sawtooth = alertPhase < 0.5 ? alertPhase * 2 : 2 - alertPhase * 2; + this._alertLight.intensity = this._alertIntensity * sawtooth; + + // Alarm housing lens glow tracks alert + if (this._alarmLensMat) { + const alertFrac = Math.min(this._alertIntensity / 2, 1); + this._alarmLensMat.emissive.setHex(alertFrac > 0.05 ? 0xff2020 : 0x000000); + this._alarmLensMat.emissiveIntensity = alertFrac * sawtooth; + } + + // Subtle ambient color shift during alerts + if (this._alertIntensity > 0.1 && this._alertLight) { + const r = 0.08 + 0.04 * sawtooth * this._alertIntensity; + const g = 0.05 - 0.02 * this._alertIntensity; + const b = 0.10 - 0.04 * this._alertIntensity; + // Shift the alert light color slightly over time + this._alertLight.color.setRGB( + Math.max(0, Math.min(1, 1.0)), + Math.max(0, Math.min(1, 0.15 - 0.1 * sawtooth)), + Math.max(0, Math.min(1, 0.2 - 0.15 * sawtooth)) + ); + } else if (this._alertLight) { + this._alertLight.color.setHex(0xff3040); + } + + // --- Camera rotation animation --- + if (this._camera1Group && this._camera1Group.visible) { + this._camera1Group.rotation.y = Math.sin(elapsed * 0.4) * 0.5; + } + if (this._camera2Group && this._camera2Group.visible) { + this._camera2Group.rotation.y = Math.sin(elapsed * 0.4 + Math.PI) * 0.5; + } + + // Camera LED blink + if (this._cam1Led && this._camera1Group?.visible) { + this._cam1Led.emissiveIntensity = (Math.sin(elapsed * 4) > 0.3) ? 1.0 : 0.1; + } + if (this._cam2Led && this._camera2Group?.visible) { + this._cam2Led.emissiveIntensity = (Math.sin(elapsed * 4 + 1) > 0.3) ? 1.0 : 0.1; + } + + // --- Screen glow color shift --- + if (this._screenGlow && this._props.screen?.visible) { + const hue = (elapsed * 0.03) % 1; + const r = 0.10 + 0.06 * Math.sin(hue * Math.PI * 2); + const g = 0.16 + 0.08 * Math.sin(hue * Math.PI * 2 + 2.1); + const b = 0.28 + 0.12 * Math.sin(hue * Math.PI * 2 + 4.2); + this._screenGlow.emissive.setRGB(r, g, b); + } + + // Power LED gentle pulse + if (this._powerLed && this._props.screen?.visible) { + this._powerLed.emissiveIntensity = 0.5 + 0.5 * Math.sin(elapsed * 2); + } + + // --- Dust particle drift near rubble --- + if (this._dustParticles && this._props.rubbleWall?.visible) { + const dPos = this._dustParticles.geometry.getAttribute('position'); + for (let i = 0; i < dPos.count; i++) { + let y = dPos.getY(i) + 0.002 * Math.sin(elapsed + i); + if (y > 2.5) y = 0; + dPos.setY(i, y); + dPos.setX(i, dPos.getX(i) + Math.sin(elapsed * 0.5 + i * 0.3) * 0.0005); + } + dPos.needsUpdate = true; + } + } +} diff --git a/observatory/js/subcarrier-manifold.js b/observatory/js/subcarrier-manifold.js new file mode 100644 index 00000000..b410aa1a --- /dev/null +++ b/observatory/js/subcarrier-manifold.js @@ -0,0 +1,163 @@ +/** + * 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/vitals-oracle.js b/observatory/js/vitals-oracle.js new file mode 100644 index 00000000..e061f800 --- /dev/null +++ b/observatory/js/vitals-oracle.js @@ -0,0 +1,187 @@ +/** + * 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(); + } +}