diff --git a/.claude-flow/data/pending-insights.jsonl b/.claude-flow/data/pending-insights.jsonl new file mode 100644 index 00000000..ae9d8876 --- /dev/null +++ b/.claude-flow/data/pending-insights.jsonl @@ -0,0 +1,13 @@ +{"type":"edit","file":"unknown","timestamp":1772725155061,"sessionId":null} +{"type":"edit","file":"unknown","timestamp":1772725341920,"sessionId":null} +{"type":"edit","file":"unknown","timestamp":1772725344759,"sessionId":null} +{"type":"edit","file":"unknown","timestamp":1772725350123,"sessionId":null} +{"type":"edit","file":"unknown","timestamp":1772725549376,"sessionId":null} +{"type":"edit","file":"unknown","timestamp":1772725716975,"sessionId":null} +{"type":"edit","file":"unknown","timestamp":1772725889463,"sessionId":null} +{"type":"edit","file":"unknown","timestamp":1772725893374,"sessionId":null} +{"type":"edit","file":"unknown","timestamp":1772726006058,"sessionId":null} +{"type":"edit","file":"unknown","timestamp":1772726169252,"sessionId":null} +{"type":"edit","file":"unknown","timestamp":1772726170029,"sessionId":null} +{"type":"edit","file":"unknown","timestamp":1772726177792,"sessionId":null} +{"type":"edit","file":"unknown","timestamp":1772726186165,"sessionId":null} diff --git a/.claude-flow/sessions/current.json b/.claude-flow/sessions/current.json new file mode 100644 index 00000000..ab76a4a2 --- /dev/null +++ b/.claude-flow/sessions/current.json @@ -0,0 +1,12 @@ +{ + "id": "session-1772726138295", + "startedAt": "2026-03-05T15:55:38.296Z", + "cwd": "C:\\Users\\ruv\\Projects\\wifi-densepose", + "context": {}, + "metrics": { + "edits": 4, + "commands": 0, + "tasks": 0, + "errors": 0 + } +} \ No newline at end of file diff --git a/firmware/esp32-csi-node/components/wasm3/wasm3-src b/firmware/esp32-csi-node/components/wasm3/wasm3-src new file mode 160000 index 00000000..79d412ea --- /dev/null +++ b/firmware/esp32-csi-node/components/wasm3/wasm3-src @@ -0,0 +1 @@ +Subproject commit 79d412ea5fcf92f0efe658d52827a0e0a96ff442 diff --git a/observatory/css/css/observatory.css b/observatory/css/css/observatory.css new file mode 100644 index 00000000..e289d65f --- /dev/null +++ b/observatory/css/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/js/convergence-engine.js b/observatory/js/js/convergence-engine.js new file mode 100644 index 00000000..f9e45f84 --- /dev/null +++ b/observatory/js/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/js/demo-data.js b/observatory/js/js/demo-data.js new file mode 100644 index 00000000..016fb059 --- /dev/null +++ b/observatory/js/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/js/holographic-panel.js b/observatory/js/js/holographic-panel.js new file mode 100644 index 00000000..97c586c0 --- /dev/null +++ b/observatory/js/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/ui/observatory/js/hud-controller.js b/observatory/js/js/hud-controller.js similarity index 99% rename from ui/observatory/js/hud-controller.js rename to observatory/js/js/hud-controller.js index 8712c5a7..14afc6b5 100644 --- a/ui/observatory/js/hud-controller.js +++ b/observatory/js/js/hud-controller.js @@ -28,7 +28,7 @@ export const DEFAULTS = { scenario: 'auto', cycle: 30, dataSource: 'demo', wsUrl: '', }; -export const SETTINGS_VERSION = '5'; +export const SETTINGS_VERSION = '6'; export const PRESETS = { foundation: {}, @@ -196,7 +196,7 @@ export class HudController { 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 * 3.0; }); + this._bindRange('opt-ambient', 'ambient', v => { obs._ambient.intensity = v * 5.0; }); this._bindRange('opt-reflect', 'reflect', v => { obs._floorMat.roughness = 1.0 - v * 0.7; obs._floorMat.metalness = v * 0.5; @@ -346,7 +346,7 @@ export class HudController { obs._applyPostSettings(); obs._renderer.toneMappingExposure = obs.settings.exposure; obs._fieldMat.opacity = obs.settings.field; - obs._ambient.intensity = obs.settings.ambient * 3.0; + obs._ambient.intensity = obs.settings.ambient * 5.0; obs._floorMat.roughness = 1.0 - obs.settings.reflect * 0.7; obs._floorMat.metalness = obs.settings.reflect * 0.5; obs._camera.fov = obs.settings.fov; diff --git a/observatory/js/js/main.js b/observatory/js/js/main.js new file mode 100644 index 00000000..26abbe2c --- /dev/null +++ b/observatory/js/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(0xccccdd, this.settings.ambient * 5.0); + this._scene.add(this._ambient); + + const hemi = new THREE.HemisphereLight(0x6688bb, 0x203040, 1.2); + this._scene.add(hemi); + + const key = new THREE.DirectionalLight(0xffeedd, 1.2); + key.position.set(4, 8, 3); + key.castShadow = true; + key.shadow.mapSize.set(1024, 1024); + key.shadow.camera.near = 0.5; + key.shadow.camera.far = 20; + key.shadow.camera.left = -8; + key.shadow.camera.right = 8; + key.shadow.camera.top = 8; + key.shadow.camera.bottom = -8; + this._scene.add(key); + + // Fill light from opposite side + const fill = new THREE.DirectionalLight(0x8899bb, 0.7); + fill.position.set(-4, 5, -2); + this._scene.add(fill); + + // Rim light from above/behind for edge definition + const rim = new THREE.DirectionalLight(0x6699cc, 0.5); + rim.position.set(0, 6, -5); + this._scene.add(rim); + + // Overhead room light — general illumination + const overhead = new THREE.PointLight(0x8899aa, 1.0, 20, 1.0); + overhead.position.set(0, 3.8, 0); + this._scene.add(overhead); + } + + // ---- Room ---- + + _buildRoom() { + this._grid = new THREE.GridHelper(12, 24, 0x1a4830, 0x0c2818); + this._grid.material.opacity = 0.5; + this._grid.material.transparent = true; + this._scene.add(this._grid); + + const boxGeo = new THREE.BoxGeometry(12, 4, 10); + const edges = new THREE.EdgesGeometry(boxGeo); + this._roomWire = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ + color: C.greenDim, opacity: 0.3, transparent: true, + })); + this._roomWire.position.y = 2; + this._scene.add(this._roomWire); + + // Reflective floor + const floorGeo = new THREE.PlaneGeometry(12, 10); + this._floorMat = new THREE.MeshStandardMaterial({ + color: 0x101810, + roughness: 1.0 - this.settings.reflect * 0.7, + metalness: this.settings.reflect * 0.5, + emissive: 0x020404, + emissiveIntensity: 0.08, + }); + const floor = new THREE.Mesh(floorGeo, this._floorMat); + floor.rotation.x = -Math.PI / 2; + floor.receiveShadow = true; + this._scene.add(floor); + + // Table under router + const tableGeo = new THREE.BoxGeometry(0.8, 0.6, 0.5); + const tableMat = new THREE.MeshStandardMaterial({ color: 0x6b5840, roughness: 0.55, emissive: 0x1a1408, emissiveIntensity: 0.25 }); + const table = new THREE.Mesh(tableGeo, tableMat); + table.position.set(-4, 0.3, -3); + table.castShadow = true; + this._scene.add(table); + } + + // ---- Router ---- + + _buildRouter() { + this._routerGroup = new THREE.Group(); + this._routerGroup.position.set(-4, 0.92, -3); + + const bodyGeo = new THREE.BoxGeometry(0.6, 0.12, 0.35); + const bodyMat = new THREE.MeshStandardMaterial({ color: 0x505060, roughness: 0.2, metalness: 0.7, emissive: 0x101018, emissiveIntensity: 0.2 }); + this._routerGroup.add(new THREE.Mesh(bodyGeo, bodyMat)); + + for (let i = -1; i <= 1; i++) { + const antGeo = new THREE.CylinderGeometry(0.015, 0.015, 0.35); + const antMat = new THREE.MeshStandardMaterial({ color: 0x606068, roughness: 0.3, metalness: 0.6, emissive: 0x101018, emissiveIntensity: 0.15 }); + const ant = new THREE.Mesh(antGeo, antMat); + ant.position.set(i * 0.2, 0.24, 0); + ant.rotation.z = i * 0.15; + this._routerGroup.add(ant); + } + + const ledGeo = new THREE.SphereGeometry(0.025); + this._routerLed = new THREE.Mesh(ledGeo, new THREE.MeshBasicMaterial({ color: C.greenGlow })); + this._routerLed.position.set(0.22, 0.07, 0.18); + this._routerGroup.add(this._routerLed); + + this._routerLight = new THREE.PointLight(C.blueSignal, 1.2, 8); + this._routerLight.position.set(0, 0.3, 0); + this._routerGroup.add(this._routerLight); + + this._scene.add(this._routerGroup); + } + + // ---- WiFi Waves ---- + + _buildWifiWaves() { + this._wifiWaves = []; + for (let i = 0; i < 5; i++) { + const radius = 0.8 + i * 1.0; + const geo = new THREE.SphereGeometry(radius, 24, 16, 0, Math.PI * 2, 0, Math.PI * 0.6); + const mat = new THREE.MeshBasicMaterial({ + color: C.blueSignal, + transparent: true, opacity: 0, + side: THREE.DoubleSide, + blending: THREE.AdditiveBlending, + depthWrite: false, wireframe: true, + }); + const shell = new THREE.Mesh(geo, mat); + shell.position.copy(this._routerGroup.position); + shell.position.y += 0.5; + this._scene.add(shell); + this._wifiWaves.push({ mesh: shell, mat, phase: i * 0.7 }); + } + } + + // ======================================== + // DOT MATRIX MIST + // ======================================== + + _buildDotMatrixMist() { + const COUNT = 800; + const positions = new Float32Array(COUNT * 3); + const alphas = new Float32Array(COUNT); + for (let i = 0; i < COUNT; i++) { + const angle = Math.random() * Math.PI * 2; + const r = Math.random() * 0.5; + positions[i * 3] = Math.cos(angle) * r; + positions[i * 3 + 1] = Math.random() * 1.8; + positions[i * 3 + 2] = Math.sin(angle) * r; + alphas[i] = 0; + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('alpha', new THREE.BufferAttribute(alphas, 1)); + const mat = new THREE.ShaderMaterial({ + vertexShader: ` + attribute float alpha; + varying float vAlpha; + void main() { + vAlpha = alpha; + vec4 mv = modelViewMatrix * vec4(position, 1.0); + gl_PointSize = 3.0 * (200.0 / -mv.z); + gl_Position = projectionMatrix * mv; + } + `, + fragmentShader: ` + uniform vec3 uColor; + varying float vAlpha; + void main() { + float d = length(gl_PointCoord - 0.5); + if (d > 0.5) discard; + float edge = smoothstep(0.5, 0.2, d); + gl_FragColor = vec4(uColor, edge * vAlpha); + } + `, + uniforms: { uColor: { value: new THREE.Color(this.settings.wireColor) } }, + transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, + }); + this._mistPoints = new THREE.Points(geo, mat); + this._scene.add(this._mistPoints); + this._mistCount = COUNT; + } + + // ---- Particle Trail ---- + + _buildParticleTrail() { + const COUNT = 200; + const positions = new Float32Array(COUNT * 3); + const ages = new Float32Array(COUNT); + for (let i = 0; i < COUNT; i++) ages[i] = 1; + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('age', new THREE.BufferAttribute(ages, 1)); + const mat = new THREE.ShaderMaterial({ + vertexShader: ` + attribute float age; + varying float vAge; + void main() { + vAge = age; + vec4 mv = modelViewMatrix * vec4(position, 1.0); + gl_PointSize = max(1.0, (1.0 - age) * 5.0 * (150.0 / -mv.z)); + gl_Position = projectionMatrix * mv; + } + `, + fragmentShader: ` + uniform vec3 uColor; + varying float vAge; + void main() { + float d = length(gl_PointCoord - 0.5); + if (d > 0.5) discard; + float alpha = (1.0 - vAge) * 0.6 * smoothstep(0.5, 0.1, d); + gl_FragColor = vec4(uColor, alpha); + } + `, + uniforms: { uColor: { value: new THREE.Color(C.greenGlow) } }, + transparent: true, blending: THREE.AdditiveBlending, depthWrite: false, + }); + this._trail = new THREE.Points(geo, mat); + this._scene.add(this._trail); + this._trailHead = 0; + this._trailCount = COUNT; + this._trailTimer = 0; + } + + // ---- Signal Field ---- + + _buildSignalField() { + const gridSize = 20; + const count = gridSize * gridSize; + const positions = new Float32Array(count * 3); + this._fieldColors = new Float32Array(count * 3); + this._fieldSizes = new Float32Array(count); + for (let iz = 0; iz < gridSize; iz++) { + for (let ix = 0; ix < gridSize; ix++) { + const idx = iz * gridSize + ix; + positions[idx * 3] = (ix - gridSize / 2) * 0.6; + positions[idx * 3 + 1] = 0.02; + positions[idx * 3 + 2] = (iz - gridSize / 2) * 0.5; + this._fieldSizes[idx] = 8; + } + } + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geo.setAttribute('color', new THREE.BufferAttribute(this._fieldColors, 3)); + geo.setAttribute('size', new THREE.BufferAttribute(this._fieldSizes, 1)); + this._fieldMat = new THREE.PointsMaterial({ + size: 0.35, vertexColors: true, transparent: true, + opacity: this.settings.field, blending: THREE.AdditiveBlending, + depthWrite: false, sizeAttenuation: true, + }); + this._fieldPoints = new THREE.Points(geo, this._fieldMat); + this._scene.add(this._fieldPoints); + } + + // ---- Keyboard ---- + + _initKeyboard() { + window.addEventListener('keydown', (e) => { + if (this._hud.settingsOpen) return; + switch (e.key.toLowerCase()) { + case 'a': + this._autopilot = !this._autopilot; + this._controls.enabled = !this._autopilot; + break; + case 'd': this._demoData.cycleScenario(); break; + case 'f': + this._showFps = !this._showFps; + document.getElementById('fps-counter').style.display = this._showFps ? 'block' : 'none'; + break; + case 's': this._hud.toggleSettings(); break; + case ' ': + e.preventDefault(); + this._demoData.paused = !this._demoData.paused; + break; + } + }); + } + + // ---- Settings / HUD methods delegated to HudController ---- + + _applyPostSettings() { + const pp = this._postProcessing; + pp._bloomPass.strength = this.settings.bloom; + pp._bloomPass.radius = this.settings.bloomRadius; + pp._bloomPass.threshold = this.settings.bloomThresh; + pp._vignettePass.uniforms.uVignetteStrength.value = this.settings.vignette; + pp._vignettePass.uniforms.uGrainStrength.value = this.settings.grain; + pp._vignettePass.uniforms.uChromaticStrength.value = this.settings.chromatic; + } + + _applyColors() { + const wc = new THREE.Color(this.settings.wireColor); + const jc = new THREE.Color(this.settings.jointColor); + this._figurePool.applyColors(wc, jc); + this._mistPoints.material.uniforms.uColor.value.copy(wc); + } + + // ---- WebSocket live data ---- + + _autoDetectLive() { + // Probe sensing server health on same origin, then common ports + const host = window.location.hostname || 'localhost'; + const candidates = [ + window.location.origin, // same origin (e.g. :3000) + `http://${host}:8765`, // default WS port + `http://${host}:3000`, // default HTTP port + ]; + // Deduplicate + const unique = [...new Set(candidates)]; + + const tryNext = (i) => { + if (i >= unique.length) { + console.log('[Observatory] No sensing server detected, using demo mode'); + return; + } + const base = unique[i]; + fetch(`${base}/health`, { signal: AbortSignal.timeout(1500) }) + .then(r => r.ok ? r.json() : Promise.reject()) + .then(data => { + if (data && data.status === 'ok') { + const wsProto = base.startsWith('https') ? 'wss:' : 'ws:'; + const urlObj = new URL(base); + const wsUrl = `${wsProto}//${urlObj.host}/ws/sensing`; + console.log('[Observatory] Sensing server detected at', base, '→', wsUrl); + this.settings.dataSource = 'ws'; + this.settings.wsUrl = wsUrl; + this._connectWS(wsUrl); + } else { + tryNext(i + 1); + } + }) + .catch(() => tryNext(i + 1)); + }; + tryNext(0); + } + + _connectWS(url) { + this._disconnectWS(); + try { + this._ws = new WebSocket(url); + this._ws.onopen = () => { + console.log('[Observatory] WebSocket connected'); + this._hud.updateSourceBadge('ws', this._ws); + }; + this._ws.onmessage = (evt) => { try { this._liveData = JSON.parse(evt.data); } catch {} }; + this._ws.onclose = () => { + console.log('[Observatory] WebSocket closed, falling back to demo'); + this._ws = null; + this.settings.dataSource = 'demo'; + this._hud.updateSourceBadge('demo', null); + }; + this._ws.onerror = () => {}; + } catch {} + } + + _disconnectWS() { + if (this._ws) { this._ws.close(); this._ws = null; } + this._liveData = null; + } + + // ======================================== + // ANIMATION LOOP + // ======================================== + + _animate() { + requestAnimationFrame(() => this._animate()); + const dt = Math.min(this._clock.getDelta(), 0.1); + const elapsed = this._clock.getElapsedTime(); + + // Data source + if (this.settings.dataSource === 'ws' && this._liveData) { + this._currentData = this._liveData; + } else { + this._currentData = this._demoData.update(dt); + } + const data = this._currentData; + + // Updates + this._nebula.update(dt, elapsed); + this._figurePool.update(data, elapsed); + this._scenarioProps.update(data, this._demoData.currentScenario); + this._updateDotMatrixMist(data, elapsed); + this._updateParticleTrail(data, dt, elapsed); + this._updateWifiWaves(elapsed); + this._updateSignalField(data); + this._hud.updateHUD(data, this._demoData); + this._hud.updateSparkline(data); + + // Router LED + this._routerLed.material.opacity = 0.5 + 0.5 * Math.sin(elapsed * 8); + this._routerLight.intensity = 0.3 + 0.2 * Math.sin(elapsed * 3); + + // Autopilot orbit + if (this._autopilot) { + this._autoAngle += dt * this.settings.orbitSpeed; + const r = 10; + this._camera.position.set( + Math.sin(this._autoAngle) * r, + 4.5 + Math.sin(this._autoAngle * 0.5), + Math.cos(this._autoAngle) * r + ); + this._controls.target.set(0, 1.2, 0); + this._controls.update(); + } + this._controls.update(); + this._postProcessing.update(elapsed); + this._postProcessing.render(); + this._updateFPS(dt); + } + + + // ======================================== + // MIST & TRAIL + // ======================================== + + _updateDotMatrixMist(data, elapsed) { + const persons = data?.persons || []; + const isPresent = data?.classification?.presence || false; + const pos = this._mistPoints.geometry.attributes.position; + const alpha = this._mistPoints.geometry.attributes.alpha; + + if (!isPresent || persons.length === 0) { + for (let i = 0; i < this._mistCount; i++) { + alpha.array[i] = Math.max(0, alpha.array[i] - 0.02); + } + alpha.needsUpdate = true; + return; + } + + // Follow primary person + const pp = persons[0].position || [0, 0, 0]; + const px = pp[0] || 0, pz = pp[2] || 0; + const ms = persons[0].motion_score || 0; + const pose = persons[0].pose || 'standing'; + const isLying = pose === 'lying' || pose === 'fallen'; + const bodyH = isLying ? 0.4 : 1.7; + const bodyBaseY = isLying ? (pp[1] || 0) + 0.05 : 0.05; + const spread = ms > 50 ? 0.6 : 0.4; + + for (let i = 0; i < this._mistCount; i++) { + const drift = Math.sin(elapsed * 0.5 + i * 0.1) * 0.003; + const angle = (i / this._mistCount) * Math.PI * 2 + elapsed * 0.1; + const layerT = (i % 20) / 20; + const layerY = bodyBaseY + layerT * bodyH; + + let bodyWidth; + if (isLying) { + bodyWidth = 0.25; + } else { + bodyWidth = layerT > 0.75 ? 0.15 : (layerT > 0.45 ? 0.25 : 0.18); + } + const r = bodyWidth * (0.5 + 0.5 * Math.sin(i * 1.7 + elapsed * 0.3)) * spread; + + const tx = px + Math.cos(angle + i * 0.3) * r + drift; + const tz = pz + Math.sin(angle + i * 0.5) * r * 0.6; + + pos.array[i * 3] += (tx - pos.array[i * 3]) * 0.05; + pos.array[i * 3 + 1] += (layerY - pos.array[i * 3 + 1]) * 0.05; + pos.array[i * 3 + 2] += (tz - pos.array[i * 3 + 2]) * 0.05; + + const targetAlpha = 0.15 + Math.sin(elapsed * 2 + i * 0.5) * 0.08; + alpha.array[i] += (targetAlpha - alpha.array[i]) * 0.08; + } + pos.needsUpdate = true; + alpha.needsUpdate = true; + } + + _updateParticleTrail(data, dt, elapsed) { + if (this.settings.trail <= 0) return; + const persons = data?.persons || []; + const isPresent = data?.classification?.presence || false; + const pos = this._trail.geometry.attributes.position; + const ages = this._trail.geometry.attributes.age; + + for (let i = 0; i < this._trailCount; i++) { + ages.array[i] = Math.min(1, ages.array[i] + dt * 0.8); + } + + // Emit from all active persons + if (isPresent && persons.length > 0) { + this._trailTimer += dt; + const ms = persons[0].motion_score || 0; + const emitRate = ms > 50 ? 0.02 : 0.08; + + if (this._trailTimer >= emitRate) { + this._trailTimer = 0; + for (const p of persons) { + const pp = p.position || [0, 0, 0]; + const idx = this._trailHead; + pos.array[idx * 3] = (pp[0] || 0) + (Math.random() - 0.5) * 0.15; + pos.array[idx * 3 + 1] = Math.random() * 1.5 + 0.1; + pos.array[idx * 3 + 2] = (pp[2] || 0) + (Math.random() - 0.5) * 0.15; + ages.array[idx] = 0; + this._trailHead = (this._trailHead + 1) % this._trailCount; + } + } + } + pos.needsUpdate = true; + ages.needsUpdate = true; + } + + // ---- WiFi Waves ---- + + _updateWifiWaves(elapsed) { + for (const w of this._wifiWaves) { + const t = (elapsed * 0.8 + w.phase) % 4.5; + const life = t / 4.5; + w.mat.opacity = Math.max(0, this.settings.waves * 0.25 * (1 - life)); + const scale = 1 + life * 0.6; + w.mesh.scale.set(scale, scale, scale); + w.mesh.rotation.y = elapsed * 0.05; + } + } + + // ---- Signal Field ---- + + _updateSignalField(data) { + const field = data?.signal_field?.values; + if (!field) return; + const count = Math.min(field.length, 400); + for (let i = 0; i < count; i++) { + const v = field[i] || 0; + let r, g, b; + if (v < 0.3) { r = 0; g = v * 1.5; b = v * 0.3; } + else if (v < 0.6) { + const t = (v - 0.3) / 0.3; + r = t * 0.3; g = 0.45 + t * 0.4; b = 0.09 - t * 0.05; + } else { + const t = (v - 0.6) / 0.4; + r = 0.3 + t * 0.7; g = 0.85 - t * 0.2; b = 0.04; + } + this._fieldColors[i * 3] = r; + this._fieldColors[i * 3 + 1] = g; + this._fieldColors[i * 3 + 2] = b; + this._fieldSizes[i] = 5 + v * 15; + } + this._fieldPoints.geometry.attributes.color.needsUpdate = true; + this._fieldPoints.geometry.attributes.size.needsUpdate = true; + } + + // ---- FPS ---- + + _updateFPS(dt) { + this._fpsFrames++; + this._fpsTime += dt; + if (this._fpsTime >= 1) { + this._fpsValue = Math.round(this._fpsFrames / this._fpsTime); + this._fpsFrames = 0; + this._fpsTime = 0; + if (this._showFps) { + document.getElementById('fps-counter').textContent = `${this._fpsValue} FPS`; + } + this._adaptQuality(); + } + } + + _adaptQuality() { + let nl = this._qualityLevel; + if (this._fpsValue < 25 && nl > 0) nl--; + else if (this._fpsValue > 55 && nl < 2) nl++; + if (nl !== this._qualityLevel) { + this._qualityLevel = nl; + this._nebula.setQuality(nl); + this._postProcessing.setQuality(nl); + } + } + + _onResize() { + const w = window.innerWidth, h = window.innerHeight; + this._camera.aspect = w / h; + this._camera.updateProjectionMatrix(); + this._renderer.setSize(w, h); + this._postProcessing.resize(w, h); + } +} + +new Observatory(); diff --git a/observatory/js/js/nebula-background.js b/observatory/js/js/nebula-background.js new file mode 100644 index 00000000..98ad7e84 --- /dev/null +++ b/observatory/js/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/js/phase-constellation.js b/observatory/js/js/phase-constellation.js new file mode 100644 index 00000000..c38444dd --- /dev/null +++ b/observatory/js/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/ui/observatory/js/post-processing.js b/observatory/js/js/post-processing.js similarity index 100% rename from ui/observatory/js/post-processing.js rename to observatory/js/js/post-processing.js diff --git a/observatory/js/js/presence-cartography.js b/observatory/js/js/presence-cartography.js new file mode 100644 index 00000000..37c6e339 --- /dev/null +++ b/observatory/js/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/js/subcarrier-manifold.js b/observatory/js/js/subcarrier-manifold.js new file mode 100644 index 00000000..b410aa1a --- /dev/null +++ b/observatory/js/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/js/vitals-oracle.js b/observatory/js/js/vitals-oracle.js new file mode 100644 index 00000000..e061f800 --- /dev/null +++ b/observatory/js/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(); + } +} diff --git a/rust-port/wifi-densepose-rs/target/debug/sensing-server.exe b/rust-port/wifi-densepose-rs/target/debug/sensing-server.exe new file mode 100644 index 00000000..7b0c53d8 Binary files /dev/null and b/rust-port/wifi-densepose-rs/target/debug/sensing-server.exe differ