diff --git a/observatory/css/css/observatory.css b/observatory/css/css/observatory.css
deleted file mode 100644
index e289d65f..00000000
--- a/observatory/css/css/observatory.css
+++ /dev/null
@@ -1,698 +0,0 @@
-/* ============================================================
- RuView Observatory — Foundation Color Scheme
- Warm dark background, electric green wireframe, amber data
- ============================================================ */
-
-@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=JetBrains+Mono:wght@400;600&display=swap');
-
-:root {
- --bg-deep: #080c14;
- --bg-panel: rgba(8, 16, 28, 0.85);
- --bg-panel-border: rgba(0, 210, 120, 0.2);
- --green-glow: #00d878;
- --green-bright:#3eff8a;
- --green-dim: #0a6b3a;
- --amber: #ffb020;
- --amber-dim: #a06800;
- --blue-signal: #2090ff;
- --blue-dim: #0a3060;
- --red-alert: #ff3040;
- --red-heart: #ff4060;
- --text-primary: #e8ece0;
- --text-secondary: rgba(232,236,224, 0.55);
- --text-label: rgba(232,236,224, 0.4);
-}
-
-* { margin: 0; padding: 0; box-sizing: border-box; }
-
-body {
- background: var(--bg-deep);
- overflow: hidden;
- font-family: 'Inter', -apple-system, sans-serif;
- color: var(--text-primary);
- -webkit-font-smoothing: antialiased;
-}
-
-#observatory-canvas {
- position: fixed;
- top: 0; left: 0;
- width: 100vw; height: 100vh;
-}
-
-/* ---- HUD Overlay ---- */
-#hud {
- position: fixed;
- top: 0; left: 0;
- width: 100%; height: 100%;
- pointer-events: none;
- z-index: 10;
-}
-
-/* ---- Brand ---- */
-#brand {
- position: absolute;
- top: 24px; left: 28px;
-}
-
-#brand-logo {
- font-family: 'Inter', sans-serif;
- font-weight: 700;
- font-size: 32px;
- color: var(--text-primary);
- letter-spacing: -0.5px;
- text-shadow: 0 0 30px rgba(0, 216, 120, 0.3);
-}
-
-.pi {
- color: var(--green-glow);
- font-style: italic;
- margin-right: 2px;
-}
-
-#brand-tagline {
- font-size: 11px;
- color: var(--text-secondary);
- letter-spacing: 1.5px;
- text-transform: uppercase;
- margin-top: 2px;
-}
-
-/* ---- Status bar (top right) ---- */
-#status-bar {
- position: absolute;
- top: 24px; right: 28px;
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-#data-source-badge {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 5px 12px;
- border-radius: 20px;
- background: rgba(0, 216, 120, 0.1);
- border: 1px solid rgba(0, 216, 120, 0.25);
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- letter-spacing: 1px;
- color: var(--green-glow);
-}
-
-.dot {
- width: 7px; height: 7px;
- border-radius: 50%;
- display: inline-block;
-}
-.dot--demo { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
-.dot--live { background: var(--green-glow); box-shadow: 0 0 6px var(--green-glow); animation: pulse-dot 2s infinite; }
-
-@keyframes pulse-dot {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.4; }
-}
-
-#scenario-area {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 5px 14px;
- border-radius: 20px;
- background: rgba(255, 176, 32, 0.1);
- border: 1px solid rgba(255, 176, 32, 0.25);
- pointer-events: auto;
-}
-#autoplay-icon {
- font-size: 10px;
- color: var(--green-glow);
- animation: pulse-dot 2s infinite;
-}
-#autoplay-icon.hidden { display: none; }
-#scenario-quick-select {
- background: none;
- border: none;
- padding: 0;
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- letter-spacing: 0.5px;
- color: var(--amber);
- cursor: pointer;
- outline: none;
-}
-#scenario-quick-select:hover,
-#scenario-quick-select:focus { color: var(--green-glow); }
-#scenario-quick-select option {
- background: #0c1420;
- color: var(--text-primary);
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- padding: 4px 8px;
-}
-
-#fps-counter {
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- color: var(--text-secondary);
-}
-
-/* ---- Data Panels ---- */
-.data-panel {
- position: absolute;
- width: 220px;
- background: var(--bg-panel);
- border: 1px solid var(--bg-panel-border);
- border-radius: 12px;
- padding: 16px;
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
- pointer-events: auto;
-}
-
-.panel-header {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- font-weight: 600;
- letter-spacing: 2px;
- text-transform: uppercase;
- color: var(--text-label);
- margin-bottom: 14px;
- padding-bottom: 8px;
- border-bottom: 1px solid rgba(255,255,255,0.06);
-}
-
-/* ---- Vitals Panel (left) ---- */
-#panel-vitals {
- left: 28px;
- top: 50%;
- transform: translateY(-50%);
-}
-
-.vital-row {
- display: flex;
- align-items: flex-start;
- gap: 12px;
- margin-bottom: 18px;
-}
-.vital-row:last-child { margin-bottom: 0; }
-
-.vital-icon {
- font-size: 20px;
- line-height: 1;
- margin-top: 2px;
- width: 24px;
- text-align: center;
-}
-
-.vital-row:nth-child(2) .vital-icon { color: var(--red-heart); }
-.vital-row:nth-child(3) .vital-icon { color: var(--green-glow); }
-.vital-row:nth-child(4) .vital-icon { color: var(--amber); }
-
-.vital-data { flex: 1; }
-
-.vital-label {
- font-size: 10px;
- color: var(--text-label);
- letter-spacing: 1px;
- text-transform: uppercase;
- margin-bottom: 3px;
-}
-
-.vital-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 26px;
- font-weight: 600;
- line-height: 1.1;
-}
-
-.vital-unit {
- font-size: 12px;
- font-weight: 400;
- color: var(--text-secondary);
-}
-
-.vital-bar {
- height: 3px;
- background: rgba(255,255,255,0.06);
- border-radius: 2px;
- margin-top: 6px;
- overflow: hidden;
-}
-
-.vital-bar-fill {
- height: 100%;
- border-radius: 2px;
- transition: width 0.5s ease;
-}
-
-.vital-bar--hr { background: var(--red-heart); width: 0%; }
-.vital-bar--br { background: var(--green-glow); width: 0%; }
-.vital-bar--conf { background: var(--amber); width: 0%; }
-
-/* ---- Signal Panel (right) ---- */
-#panel-signal {
- right: 28px;
- top: 50%;
- transform: translateY(-50%);
-}
-
-.signal-row {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 8px;
-}
-
-.signal-label {
- font-size: 11px;
- color: var(--text-label);
- letter-spacing: 0.5px;
-}
-
-.signal-value {
- font-family: 'JetBrains Mono', monospace;
- font-size: 13px;
- font-weight: 600;
- color: var(--blue-signal);
-}
-
-#rssi-sparkline {
- width: 100%;
- height: 48px;
- margin-top: 8px;
- border-radius: 6px;
- background: rgba(0,0,0,0.3);
-}
-
-/* Presence */
-.presence-state {
- text-align: center;
- padding: 8px;
- border-radius: 8px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 14px;
- font-weight: 600;
- letter-spacing: 2px;
- transition: all 0.5s ease;
-}
-
-.presence--absent {
- background: rgba(255,255,255,0.03);
- color: var(--text-label);
- border: 1px solid rgba(255,255,255,0.05);
-}
-
-.presence--present {
- background: rgba(0, 216, 120, 0.1);
- color: var(--green-glow);
- border: 1px solid rgba(0, 216, 120, 0.3);
- box-shadow: 0 0 20px rgba(0, 216, 120, 0.1);
-}
-
-.presence--active {
- background: rgba(255, 176, 32, 0.1);
- color: var(--amber);
- border: 1px solid rgba(255, 176, 32, 0.3);
- box-shadow: 0 0 20px rgba(255, 176, 32, 0.1);
-}
-
-.fall-alert {
- margin-top: 10px;
- text-align: center;
- padding: 8px;
- border-radius: 8px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 12px;
- font-weight: 700;
- letter-spacing: 2px;
- background: rgba(255, 48, 64, 0.15);
- color: var(--red-alert);
- border: 1px solid rgba(255, 48, 64, 0.4);
- animation: pulse-alert 0.8s infinite;
-}
-
-@keyframes pulse-alert {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.5; }
-}
-
-/* ---- Capabilities Bar (bottom center) ---- */
-#capabilities-bar {
- position: absolute;
- bottom: 20px;
- left: 50%;
- transform: translateX(-50%);
- display: flex;
- align-items: center;
- gap: 0;
- background: var(--bg-panel);
- border: 1px solid var(--bg-panel-border);
- border-radius: 30px;
- padding: 8px 24px;
- backdrop-filter: blur(12px);
-}
-
-.cap-item {
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 12px;
- font-weight: 500;
- color: var(--text-secondary);
- padding: 0 16px;
-}
-
-.cap-icon {
- font-size: 16px;
- color: var(--green-glow);
-}
-
-.cap-item:nth-child(3) .cap-icon { color: var(--red-heart); }
-.cap-item:nth-child(5) .cap-icon { color: var(--blue-signal); }
-
-.cap-divider {
- width: 1px;
- height: 20px;
- background: rgba(255,255,255,0.1);
-}
-
-/* ---- Key hints ---- */
-#key-hints {
- position: absolute;
- bottom: 24px;
- right: 28px;
- display: flex;
- gap: 8px;
-}
-
-.key-hint {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: rgba(255,255,255,0.2);
- letter-spacing: 0.5px;
- padding: 3px 8px;
- border-radius: 4px;
- background: rgba(255,255,255,0.03);
- border: 1px solid rgba(255,255,255,0.05);
-}
-
-/* ---- Settings button ---- */
-#settings-btn {
- pointer-events: auto;
- background: rgba(255,255,255,0.06);
- border: 1px solid rgba(255,255,255,0.1);
- color: var(--text-secondary);
- font-size: 18px;
- width: 34px; height: 34px;
- border-radius: 50%;
- cursor: pointer;
- transition: all 0.2s;
- display: flex; align-items: center; justify-content: center;
- padding: 0;
-}
-#settings-btn:hover {
- background: rgba(0, 216, 120, 0.15);
- border-color: var(--green-glow);
- color: var(--green-glow);
-}
-
-/* ---- Settings Dialog ---- */
-.settings-overlay {
- position: fixed;
- top: 0; left: 0;
- width: 100%; height: 100%;
- z-index: 100;
- background: rgba(0,0,0,0.5);
- backdrop-filter: blur(4px);
- display: flex;
- align-items: center;
- justify-content: center;
- pointer-events: auto;
-}
-
-.settings-dialog {
- background: rgba(10, 16, 28, 0.96);
- border: 1px solid rgba(0, 216, 120, 0.2);
- border-radius: 16px;
- width: 440px;
- max-height: 80vh;
- overflow-y: auto;
- padding: 0;
- box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 40px rgba(0,216,120,0.05);
-}
-
-.settings-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 16px 20px;
- border-bottom: 1px solid rgba(255,255,255,0.06);
- font-family: 'JetBrains Mono', monospace;
- font-size: 13px;
- font-weight: 600;
- letter-spacing: 1px;
- text-transform: uppercase;
- color: var(--text-primary);
-}
-
-.settings-header button {
- background: none;
- border: none;
- color: var(--text-secondary);
- font-size: 22px;
- cursor: pointer;
- padding: 0 4px;
- line-height: 1;
-}
-.settings-header button:hover { color: var(--red-alert); }
-
-.settings-tabs {
- display: flex;
- border-bottom: 1px solid rgba(255,255,255,0.06);
- padding: 0 12px;
-}
-
-.stab {
- background: none;
- border: none;
- color: var(--text-label);
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- letter-spacing: 1px;
- text-transform: uppercase;
- padding: 10px 14px;
- cursor: pointer;
- border-bottom: 2px solid transparent;
- transition: all 0.2s;
-}
-.stab:hover { color: var(--text-secondary); }
-.stab.active {
- color: var(--green-glow);
- border-bottom-color: var(--green-glow);
-}
-
-.stab-content {
- display: none;
- padding: 16px 20px;
-}
-.stab-content.active { display: block; }
-
-.setting-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- margin-bottom: 14px;
- font-size: 12px;
- color: var(--text-secondary);
-}
-
-.setting-row span:first-child {
- min-width: 120px;
- flex-shrink: 0;
-}
-
-.setting-row input[type="range"] {
- flex: 1;
- height: 4px;
- -webkit-appearance: none;
- appearance: none;
- background: rgba(255,255,255,0.08);
- border-radius: 2px;
- outline: none;
-}
-.setting-row input[type="range"]::-webkit-slider-thumb {
- -webkit-appearance: none;
- width: 14px; height: 14px;
- border-radius: 50%;
- background: var(--green-glow);
- cursor: pointer;
- box-shadow: 0 0 6px rgba(0,216,120,0.4);
-}
-
-.setting-row input[type="color"] {
- -webkit-appearance: none;
- width: 36px; height: 24px;
- border: 1px solid rgba(255,255,255,0.15);
- border-radius: 4px;
- background: none;
- cursor: pointer;
- padding: 0;
-}
-.setting-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 2px; }
-.setting-row input[type="color"]::-webkit-color-swatch { border-radius: 2px; border: none; }
-
-.setting-row select,
-.setting-row input[type="text"] {
- flex: 1;
- background: #0c1420;
- border: 1px solid rgba(255,255,255,0.1);
- color: var(--text-primary);
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- padding: 6px 10px;
- border-radius: 6px;
- outline: none;
-}
-.setting-row select:focus,
-.setting-row input[type="text"]:focus {
- border-color: var(--green-glow);
-}
-.setting-row select option {
- background: #0c1420;
- color: var(--text-primary);
- padding: 6px 10px;
-}
-.setting-row select optgroup {
- background: #0a1018;
- color: var(--green-glow);
- font-style: normal;
- font-weight: 600;
- padding: 4px 0;
-}
-
-.setting-row input[type="checkbox"] {
- width: 18px; height: 18px;
- accent-color: var(--green-glow);
- cursor: pointer;
-}
-
-.check-row {
- flex-direction: row;
-}
-
-.range-val {
- font-family: 'JetBrains Mono', monospace;
- font-size: 10px;
- color: var(--green-glow);
- min-width: 44px;
- text-align: right;
-}
-
-.settings-btn {
- width: 100%;
- padding: 8px;
- margin-top: 6px;
- background: rgba(0, 216, 120, 0.08);
- border: 1px solid rgba(0, 216, 120, 0.2);
- color: var(--green-glow);
- font-family: 'JetBrains Mono', monospace;
- font-size: 11px;
- letter-spacing: 1px;
- border-radius: 6px;
- cursor: pointer;
- transition: all 0.2s;
-}
-.settings-btn:hover {
- background: rgba(0, 216, 120, 0.15);
- border-color: var(--green-glow);
-}
-
-/* ---- Scenario Description ---- */
-#scenario-description {
- position: absolute;
- top: 60px;
- right: 28px;
- max-width: 340px;
- font-size: 11px;
- color: var(--text-secondary);
- font-style: italic;
- letter-spacing: 0.3px;
- line-height: 1.4;
- pointer-events: none;
- opacity: 0.7;
- transition: opacity 0.5s ease;
-}
-
-/* ---- Edge Module Badges ---- */
-#edge-modules-bar {
- position: absolute;
- bottom: 58px;
- left: 50%;
- transform: translateX(-50%);
- display: flex;
- align-items: center;
- gap: 6px;
- pointer-events: none;
-}
-
-.edge-badge {
- display: inline-block;
- padding: 2px 8px;
- border-radius: 10px;
- font-family: 'JetBrains Mono', monospace;
- font-size: 9px;
- font-weight: 600;
- letter-spacing: 1px;
- color: var(--badge-color, var(--text-secondary));
- background: rgba(255,255,255,0.04);
- border: 1px solid var(--badge-color, rgba(255,255,255,0.1));
- box-shadow: 0 0 6px color-mix(in srgb, var(--badge-color, transparent) 30%, transparent);
-}
-
-/* ---- Person Count Dots ---- */
-.persons-dots {
- display: inline-flex;
- align-items: center;
- gap: 3px;
- margin-left: 6px;
- vertical-align: middle;
-}
-
-.person-dot {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- display: inline-block;
- background: rgba(255,255,255,0.08);
- border: 1px solid rgba(255,255,255,0.1);
- transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease;
-}
-
-.person-dot--active {
- background: var(--green-glow);
- border-color: var(--green-glow);
- box-shadow: 0 0 4px rgba(0, 216, 120, 0.4);
-}
-
-/* ---- Vital Value Color Transitions ---- */
-.vital-value span:first-child {
- transition: color 0.6s ease;
-}
-
-.vital-bar-fill {
- transition: width 0.5s ease, background 0.6s ease;
-}
-
-/* ---- Responsive ---- */
-@media (max-width: 1200px) {
- .data-panel { width: 190px; padding: 12px; }
- .vital-value { font-size: 22px; }
- #capabilities-bar { display: none; }
-}
-
-@media (max-width: 800px) {
- .data-panel { display: none; }
- #key-hints { display: none; }
- .settings-dialog { width: 95vw; }
-}
diff --git a/observatory/js/js/convergence-engine.js b/observatory/js/js/convergence-engine.js
deleted file mode 100644
index f9e45f84..00000000
--- a/observatory/js/js/convergence-engine.js
+++ /dev/null
@@ -1,221 +0,0 @@
-/**
- * Module E — "Statistical Convergence Engine"
- * RSSI waveform, person orbs, classification, fall alert, metric bars
- */
-import * as THREE from 'three';
-
-const WAVEFORM_POINTS = 120;
-
-export class ConvergenceEngine {
- constructor(scene, panelGroup) {
- this.group = new THREE.Group();
- if (panelGroup) panelGroup.add(this.group);
- else scene.add(this.group);
-
- // --- RSSI Waveform (scrolling line) ---
- this._rssiHistory = new Float32Array(WAVEFORM_POINTS);
- const waveGeo = new THREE.BufferGeometry();
- this._wavePositions = new Float32Array(WAVEFORM_POINTS * 3);
- for (let i = 0; i < WAVEFORM_POINTS; i++) {
- this._wavePositions[i * 3] = (i / WAVEFORM_POINTS) * 6 - 3; // x: -3 to 3
- this._wavePositions[i * 3 + 1] = 0;
- this._wavePositions[i * 3 + 2] = 0;
- }
- waveGeo.setAttribute('position', new THREE.BufferAttribute(this._wavePositions, 3));
- const waveMat = new THREE.LineBasicMaterial({
- color: 0x00d4ff,
- transparent: true,
- opacity: 0.8,
- blending: THREE.AdditiveBlending,
- });
- this._waveform = new THREE.Line(waveGeo, waveMat);
- this._waveform.position.y = 1.5;
- this.group.add(this._waveform);
-
- // Waveform glow (thicker, dimmer duplicate)
- const glowMat = new THREE.LineBasicMaterial({
- color: 0x00d4ff,
- transparent: true,
- opacity: 0.2,
- linewidth: 2,
- blending: THREE.AdditiveBlending,
- });
- this._waveGlow = new THREE.Line(waveGeo.clone(), glowMat);
- this._waveGlow.position.y = 1.5;
- this._waveGlow.scale.set(1, 1.3, 1);
- this.group.add(this._waveGlow);
-
- // --- Person orbs (up to 4) ---
- this._personOrbs = [];
- for (let i = 0; i < 4; i++) {
- const orbGeo = new THREE.SphereGeometry(0.2, 16, 16);
- const orbMat = new THREE.MeshBasicMaterial({
- color: 0xff8800,
- transparent: true,
- opacity: 0,
- blending: THREE.AdditiveBlending,
- });
- const orb = new THREE.Mesh(orbGeo, orbMat);
- orb.position.set(-2 + i * 1.2, -0.5, 0);
- this.group.add(orb);
-
- const light = new THREE.PointLight(0xff8800, 0, 3);
- orb.add(light);
-
- this._personOrbs.push({ mesh: orb, light, mat: orbMat });
- }
-
- // --- Classification text sprite ---
- this._classCanvas = document.createElement('canvas');
- this._classCanvas.width = 256;
- this._classCanvas.height = 48;
- this._classCtx = this._classCanvas.getContext('2d');
- this._classTex = new THREE.CanvasTexture(this._classCanvas);
- const classMat = new THREE.SpriteMaterial({
- map: this._classTex,
- transparent: true,
- blending: THREE.AdditiveBlending,
- depthWrite: false,
- });
- this._classSprite = new THREE.Sprite(classMat);
- this._classSprite.scale.set(3, 0.6, 1);
- this._classSprite.position.y = 0.3;
- this.group.add(this._classSprite);
-
- // --- Fall alert ring ---
- const alertGeo = new THREE.TorusGeometry(2.5, 0.05, 8, 48);
- this._alertMat = new THREE.MeshBasicMaterial({
- color: 0xff2244,
- transparent: true,
- opacity: 0,
- blending: THREE.AdditiveBlending,
- depthWrite: false,
- });
- this._alertRing = new THREE.Mesh(alertGeo, this._alertMat);
- this._alertRing.rotation.x = Math.PI / 2;
- this._alertRing.position.y = -1;
- this.group.add(this._alertRing);
-
- // --- Metric bars (3: frame rate, confidence, variance) ---
- this._metricBars = [];
- const barLabels = ['CONF', 'VAR', 'SPEC'];
- for (let i = 0; i < 3; i++) {
- const barGeo = new THREE.PlaneGeometry(0.15, 1.5);
- const barMat = new THREE.MeshBasicMaterial({
- color: [0x00d4ff, 0x8844ff, 0xff8800][i],
- transparent: true,
- opacity: 0.5,
- blending: THREE.AdditiveBlending,
- depthWrite: false,
- side: THREE.DoubleSide,
- });
- const bar = new THREE.Mesh(barGeo, barMat);
- bar.position.set(2 + i * 0.4, -1.2, 0);
- this.group.add(bar);
- this._metricBars.push({ mesh: bar, mat: barMat });
- }
-
- this._rssiHead = 0;
- this._lastClassification = '';
- }
-
- update(dt, elapsed, data) {
- const features = data?.features || {};
- const classification = data?.classification || {};
- const persons = data?.persons || [];
- const estPersons = data?.estimated_persons || 0;
-
- // --- Update RSSI waveform ---
- const rssi = features.mean_rssi || -50;
- this._rssiHistory[this._rssiHead] = rssi;
- this._rssiHead = (this._rssiHead + 1) % WAVEFORM_POINTS;
-
- for (let i = 0; i < WAVEFORM_POINTS; i++) {
- const histIdx = (this._rssiHead + i) % WAVEFORM_POINTS;
- const val = this._rssiHistory[histIdx];
- // Normalize RSSI (-80 to -20 range) to -1.5 to 1.5
- this._wavePositions[i * 3 + 1] = ((val + 50) / 30) * 1.5;
- }
- this._waveform.geometry.attributes.position.needsUpdate = true;
-
- // Copy to glow
- const glowPos = this._waveGlow.geometry.attributes.position;
- glowPos.array.set(this._wavePositions);
- glowPos.needsUpdate = true;
-
- // --- Person orbs ---
- for (let i = 0; i < this._personOrbs.length; i++) {
- const { mesh, light, mat } = this._personOrbs[i];
- if (i < estPersons) {
- mat.opacity = 0.7;
- light.intensity = 1.0 + Math.sin(elapsed * 3 + i * 1.5) * 0.5;
- const pulse = 1.0 + Math.sin(elapsed * 2 + i) * 0.15;
- mesh.scale.set(pulse, pulse, pulse);
- } else {
- mat.opacity = 0.05;
- light.intensity = 0;
- mesh.scale.set(0.5, 0.5, 0.5);
- }
- }
-
- // --- Classification text ---
- const motionLevel = classification.motion_level || 'absent';
- const label = motionLevel.toUpperCase().replace('_', ' ');
- if (label !== this._lastClassification) {
- this._lastClassification = label;
- const ctx = this._classCtx;
- ctx.clearRect(0, 0, 256, 48);
- ctx.font = '600 24px "Courier New", monospace';
- ctx.textAlign = 'center';
-
- if (motionLevel === 'active') ctx.fillStyle = '#ff8800';
- else if (motionLevel.includes('present')) ctx.fillStyle = '#00d4ff';
- else ctx.fillStyle = '#445566';
-
- ctx.fillText(label, 128, 32);
- this._classTex.needsUpdate = true;
- }
-
- // --- Fall alert ---
- const fallDetected = classification.fall_detected || false;
- if (fallDetected) {
- this._alertMat.opacity = 0.3 + Math.abs(Math.sin(elapsed * 6)) * 0.5;
- const scale = 1.0 + Math.sin(elapsed * 4) * 0.1;
- this._alertRing.scale.set(scale, scale, 1);
- } else {
- this._alertMat.opacity = 0;
- }
-
- // --- Metric bars ---
- const confidence = classification.confidence || 0;
- const variance = Math.min(1, (features.variance || 0) / 5);
- const spectral = Math.min(1, (features.spectral_power || 0) / 0.5);
- const values = [confidence, variance, spectral];
-
- for (let i = 0; i < 3; i++) {
- const bar = this._metricBars[i];
- const v = values[i];
- bar.mesh.scale.y = Math.max(0.05, v);
- bar.mesh.position.y = -1.2 + v * 0.75;
- bar.mat.opacity = 0.3 + v * 0.4;
- }
- }
-
- dispose() {
- this._waveform.geometry.dispose();
- this._waveform.material.dispose();
- this._waveGlow.geometry.dispose();
- this._waveGlow.material.dispose();
- this._alertRing.geometry.dispose();
- this._alertMat.dispose();
- this._classTex.dispose();
- for (const { mesh, mat } of this._personOrbs) {
- mesh.geometry.dispose();
- mat.dispose();
- }
- for (const { mesh, mat } of this._metricBars) {
- mesh.geometry.dispose();
- mat.dispose();
- }
- }
-}
diff --git a/observatory/js/js/demo-data.js b/observatory/js/js/demo-data.js
deleted file mode 100644
index 016fb059..00000000
--- a/observatory/js/js/demo-data.js
+++ /dev/null
@@ -1,1794 +0,0 @@
-/**
- * Demo Data Generator — RuView Observatory
- *
- * Generates synthetic CSI data matching the SensingUpdate contract.
- * 12 scenarios covering all edge module categories.
- * Each person includes pose, facing, and scenario-specific motion data.
- * Auto-cycles with cosine crossfade transitions.
- *
- * V2: Enhanced with temporally-correlated noise, spatially-coherent fields,
- * physiologically accurate vital signs, and realistic behavioral patterns.
- */
-
-const SCENARIOS = [
- 'empty_room',
- 'single_breathing',
- 'two_walking',
- 'fall_event',
- 'sleep_monitoring',
- 'intrusion_detect',
- 'gesture_control',
- 'crowd_occupancy',
- 'search_rescue',
- 'elderly_care',
- 'fitness_tracking',
- 'security_patrol',
-];
-
-const CROSSFADE_DURATION = 2; // seconds
-
-// ---------------------------------------------------------------------------
-// Noise & utility functions (module-private)
-// ---------------------------------------------------------------------------
-
-/** Seeded PRNG for deterministic per-scenario noise. */
-function _mulberry32(seed) {
- return function () {
- let t = (seed += 0x6d2b79f5);
- t = Math.imul(t ^ (t >>> 15), t | 1);
- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
- };
-}
-
-/**
- * Temporally-correlated noise (1st-order IIR low-pass filtered white noise).
- * Returns a function noise(t) that produces smooth, non-teleporting values
- * in approximately [-amplitude, +amplitude].
- * `smoothing` controls correlation: higher = smoother (0.9-0.99 typical).
- */
-function _makeCorrelatedNoise(seed, smoothing = 0.95, amplitude = 1) {
- const rng = _mulberry32(seed);
- let state = 0;
- let lastT = -1;
- return function (t) {
- // Step the filter forward for each new time tick
- const steps = Math.max(1, Math.round((t - lastT) * 60)); // ~60 Hz internal
- for (let i = 0; i < Math.min(steps, 120); i++) {
- state = smoothing * state + (1 - smoothing) * (rng() * 2 - 1);
- }
- lastT = t;
- return state * amplitude;
- };
-}
-
-/**
- * Perlin-like 1D noise via sine harmonics.
- * Deterministic, smooth, and cheap.
- */
-function _harmonicNoise(t, seed, octaves = 3) {
- let v = 0, amp = 1, freq = 1;
- for (let i = 0; i < octaves; i++) {
- v += amp * Math.sin(t * freq + seed * (i + 1) * 1.618);
- amp *= 0.5;
- freq *= 2.17;
- }
- return v;
-}
-
-/** Smooth step (hermite interpolation) */
-function _smoothstep(edge0, edge1, x) {
- const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
- return t * t * (3 - 2 * t);
-}
-
-/** Clamp */
-function _clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
-
-/** Lerp */
-function _lerp(a, b, t) { return a + (b - a) * t; }
-
-/** Gaussian blob value at distance d with given sigma */
-function _gaussian(d, sigma) {
- return Math.exp(-(d * d) / (2 * sigma * sigma));
-}
-
-// ---------------------------------------------------------------------------
-// Noise bank — pre-allocated correlated noise channels per scenario
-// Each scenario gets its own set of noise functions so they don't interfere.
-// ---------------------------------------------------------------------------
-const _noiseBanks = {};
-function _getNoiseBank(scenario) {
- if (!_noiseBanks[scenario]) {
- const idx = SCENARIOS.indexOf(scenario);
- const base = (idx + 1) * 1000;
- _noiseBanks[scenario] = {
- rssi: _makeCorrelatedNoise(base + 1, 0.97, 1.5),
- breath: _makeCorrelatedNoise(base + 2, 0.92, 0.3),
- hr: _makeCorrelatedNoise(base + 3, 0.94, 1.0),
- motion: _makeCorrelatedNoise(base + 4, 0.90, 0.5),
- field: _makeCorrelatedNoise(base + 5, 0.96, 0.2),
- pos1: _makeCorrelatedNoise(base + 6, 0.98, 0.15),
- pos2: _makeCorrelatedNoise(base + 7, 0.98, 0.15),
- env: _makeCorrelatedNoise(base + 8, 0.99, 1.0),
- spike: _makeCorrelatedNoise(base + 9, 0.80, 1.0),
- };
- }
- return _noiseBanks[scenario];
-}
-
-
-export class DemoDataGenerator {
- constructor() {
- this._scenarioIndex = 0;
- this._elapsed = 0;
- this._paused = false;
- this._prevFrame = null;
- this._currFrame = null;
- this._cycleDuration = 30;
- this._autoMode = true;
- }
-
- get currentScenario() {
- return SCENARIOS[this._scenarioIndex];
- }
-
- get paused() { return this._paused; }
- set paused(v) { this._paused = v; }
-
- cycleScenario() {
- this._scenarioIndex = (this._scenarioIndex + 1) % SCENARIOS.length;
- this._elapsed = 0;
- }
-
- setScenario(name) {
- const idx = SCENARIOS.indexOf(name);
- if (idx >= 0) {
- this._scenarioIndex = idx;
- this._autoMode = false;
- this._elapsed = 0;
- } else if (name === 'auto') {
- this._autoMode = true;
- }
- }
-
- setCycleDuration(seconds) {
- this._cycleDuration = Math.max(5, seconds);
- }
-
- /** Call each frame; returns blended SensingUpdate object */
- update(dt) {
- if (this._paused) {
- return this._currFrame || this._generate(this._scenarioIndex, this._elapsed);
- }
-
- this._elapsed += dt;
-
- // Auto-cycle
- if (this._autoMode && this._elapsed >= this._cycleDuration) {
- this._elapsed -= this._cycleDuration;
- this._scenarioIndex = (this._scenarioIndex + 1) % SCENARIOS.length;
- }
-
- const t = this._elapsed;
- const frame = this._generate(this._scenarioIndex, t);
-
- // Crossfade near transition boundaries
- if (this._autoMode && t < CROSSFADE_DURATION) {
- const prevIdx = (this._scenarioIndex - 1 + SCENARIOS.length) % SCENARIOS.length;
- const prevFrame = this._generate(prevIdx, this._cycleDuration - CROSSFADE_DURATION + t);
- const alpha = 0.5 + 0.5 * Math.cos(Math.PI * (1 - t / CROSSFADE_DURATION));
- this._currFrame = this._blend(prevFrame, frame, alpha);
- } else {
- this._currFrame = frame;
- }
-
- return this._currFrame;
- }
-
- // ---- Scenario generators ----
-
- _generate(scenarioIdx, t) {
- const name = SCENARIOS[scenarioIdx];
- switch (name) {
- case 'empty_room': return this._emptyRoom(t);
- case 'single_breathing': return this._singleBreathing(t);
- case 'two_walking': return this._twoWalking(t);
- case 'fall_event': return this._fallEvent(t);
- case 'sleep_monitoring': return this._sleepMonitoring(t);
- case 'intrusion_detect': return this._intrusionDetect(t);
- case 'gesture_control': return this._gestureControl(t);
- case 'crowd_occupancy': return this._crowdOccupancy(t);
- case 'search_rescue': return this._searchRescue(t);
- case 'elderly_care': return this._elderlyCare(t);
- case 'fitness_tracking': return this._fitnessTracking(t);
- case 'security_patrol': return this._securityPatrol(t);
- default: return this._emptyRoom(t);
- }
- }
-
- // ---- Base template ----
-
- _baseFrame(overrides) {
- return {
- type: 'sensing_update',
- timestamp: Date.now() / 1000,
- source: 'demo',
- scenario: SCENARIOS[this._scenarioIndex],
- nodes: [{ node_id: 1, rssi_dbm: -45, position: [2, 0, 1.5], amplitude: new Float32Array(64), subcarrier_count: 64 }],
- features: { mean_rssi: -45, variance: 0.3, std: 0.55, motion_band_power: 0.02, breathing_band_power: 0.01, dominant_freq_hz: 0.05, spectral_power: 0.03 },
- classification: { motion_level: 'absent', presence: false, confidence: 0.92 },
- signal_field: { grid_size: [20, 1, 20], values: this._flatField(0.05) },
- vital_signs: { breathing_rate_bpm: 0, heart_rate_bpm: 0, breathing_confidence: 0, heart_rate_confidence: 0 },
- persons: [],
- estimated_persons: 0,
- edge_modules: {},
- _observatory: { subcarrier_iq: [], per_subcarrier_variance: new Float32Array(64).fill(0.02) },
- ...overrides,
- };
- }
-
- // ========================================================================
- // 1. Empty Room — environmental noise, interference spikes, day/night drift
- // ========================================================================
-
- _emptyRoom(t) {
- const n = _getNoiseBank('empty_room');
-
- // Day/night RSSI drift: slow sinusoidal cycle over the scenario duration
- const dayNightDrift = Math.sin(t * 0.08) * 3;
- // Occasional microwave/device interference spike
- const spikeRaw = n.spike(t);
- const interferenceSpike = spikeRaw > 0.7 ? (spikeRaw - 0.7) * 15 : 0;
- // Subtle HVAC cycling
- const hvacCycle = Math.sin(t * 0.4) * 0.5 + Math.sin(t * 1.1) * 0.2;
-
- const baseRssi = -45 + dayNightDrift + n.rssi(t) + interferenceSpike;
-
- const amplitude = new Float32Array(64);
- for (let i = 0; i < 64; i++) {
- // Base floor with harmonic variation per subcarrier
- const subNoise = _harmonicNoise(t, i * 0.37, 2) * 0.02;
- // Interference affects specific subcarrier bands (like a microwave in 2.4GHz)
- const microBand = (i >= 20 && i <= 35) ? interferenceSpike * 0.03 : 0;
- amplitude[i] = 0.1 + subNoise + microBand + Math.abs(hvacCycle) * 0.01;
- }
-
- // Signal field with subtle ripple patterns (standing waves in empty room)
- const vals = [];
- for (let iz = 0; iz < 20; iz++) {
- for (let ix = 0; ix < 20; ix++) {
- const standingWave = Math.sin(ix * 0.8 + t * 0.3) * Math.sin(iz * 0.6 + t * 0.2) * 0.015;
- const fieldNoise = _harmonicNoise(t + ix * 0.5 + iz * 0.7, ix + iz * 20, 2) * 0.008;
- const ripple = interferenceSpike > 0
- ? _gaussian(Math.sqrt((ix - 10) ** 2 + (iz - 10) ** 2), 8) * interferenceSpike * 0.02
- : 0;
- vals.push(_clamp(0.05 + standingWave + fieldNoise + ripple, 0, 1));
- }
- }
-
- return this._baseFrame({
- nodes: [{ node_id: 1, rssi_dbm: baseRssi, position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }],
- features: {
- mean_rssi: baseRssi,
- variance: 0.3 + Math.abs(n.env(t)) * 0.15 + interferenceSpike * 0.5,
- std: 0.55 + interferenceSpike * 0.2,
- motion_band_power: 0.02 + interferenceSpike * 0.08 + Math.abs(hvacCycle) * 0.005,
- breathing_band_power: 0.01 + Math.abs(hvacCycle) * 0.003,
- dominant_freq_hz: interferenceSpike > 0.5 ? 2.45 : 0.05 + Math.abs(hvacCycle) * 0.02,
- spectral_power: 0.03 + interferenceSpike * 0.1,
- },
- signal_field: { grid_size: [20, 1, 20], values: vals },
- edge_modules: {
- environment: {
- interference_detected: interferenceSpike > 0.5,
- interference_band: interferenceSpike > 0.5 ? '2.4GHz_microwave' : 'none',
- ambient_drift: dayNightDrift.toFixed(2),
- },
- },
- });
- }
-
- // ========================================================================
- // 2. Single Breathing — HRV, respiratory sinus arrhythmia, natural irregularity
- // ========================================================================
-
- _singleBreathing(t) {
- const n = _getNoiseBank('single_breathing');
-
- // Natural breathing: ~16 BPM but with irregularity
- // Breathing rate varies slightly over time (14.5-17.5)
- const breathRateBase = 16 + _harmonicNoise(t, 1.23, 2) * 1.5;
- const breathFreq = breathRateBase / 60;
- // Accumulate phase for non-uniform period
- const breathPhase = Math.sin(2 * Math.PI * breathFreq * t + n.breath(t) * 0.4);
- // Inhale is slightly shorter than exhale (1:1.5 ratio via asymmetric wave)
- const breathSignal = breathPhase > 0
- ? Math.sin(Math.asin(breathPhase) * 1.3)
- : breathPhase * 0.85;
-
- // Heart Rate Variability (HRV): base 72 BPM, varies 68-76
- // Respiratory Sinus Arrhythmia: HR increases on inhale, decreases on exhale
- const rsaEffect = breathSignal * 3.0; // +/-3 BPM with breathing
- const hrvWander = _harmonicNoise(t, 7.77, 3) * 2.0; // slow HRV drift
- const instantHR = 72 + rsaEffect + hrvWander + n.hr(t) * 0.5;
- const hrFreq = instantHR / 60;
- const hrPhase = Math.sin(2 * Math.PI * hrFreq * t);
-
- const amplitude = new Float32Array(64);
- for (let i = 0; i < 64; i++) {
- const subBase = 0.4 + 0.2 * Math.sin(t * 0.5 + i * 0.15);
- const breathMod = breathSignal * 0.08 * (1 + 0.3 * Math.sin(i * 0.4)); // subcarrier-dependent
- const hrMod = hrPhase * 0.015 * (i > 20 && i < 45 ? 1.5 : 0.5); // HR stronger in mid-band
- amplitude[i] = subBase + breathMod + hrMod + _harmonicNoise(t, i * 0.13, 2) * 0.01;
- }
-
- const rssiBase = -42 + breathSignal * 1.5 + n.rssi(t) * 0.5;
-
- return this._baseFrame({
- nodes: [{ node_id: 1, rssi_dbm: rssiBase, position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }],
- features: {
- mean_rssi: rssiBase,
- variance: 1.8 + breathSignal * 0.3 + Math.abs(n.motion(t)) * 0.1,
- std: 1.34 + Math.abs(breathSignal) * 0.1,
- motion_band_power: 0.04 + Math.abs(breathSignal) * 0.02,
- breathing_band_power: 0.12 + breathSignal * 0.04,
- dominant_freq_hz: breathFreq,
- spectral_power: 0.18 + Math.abs(hrPhase) * 0.03,
- },
- classification: { motion_level: 'present_still', presence: true, confidence: 0.88 + breathSignal * 0.03 },
- signal_field: { grid_size: [20, 1, 20], values: this._presenceField(10, 10, 2.5, t) },
- vital_signs: {
- breathing_rate_bpm: breathRateBase,
- heart_rate_bpm: instantHR,
- breathing_confidence: 0.85 + breathSignal * 0.05,
- heart_rate_confidence: 0.75 + hrPhase * 0.05,
- hrv_ms: 35 + _harmonicNoise(t, 3.14, 2) * 15, // RMSSD-like HRV metric
- rsa_active: true,
- },
- persons: [{ id: 'p0', position: [0 + n.pos1(t) * 0.05, 0, 0 + n.pos2(t) * 0.05], motion_score: 15, pose: 'standing', facing: 0 }],
- estimated_persons: 1,
- edge_modules: {
- vital_trend: { status: 'normal', trend: 'stable', hrv_quality: 'good' },
- cardiac_detail: { rsa_amplitude_bpm: Math.abs(rsaEffect).toFixed(1), hrv_rmssd_ms: (35 + _harmonicNoise(t, 3.14, 2) * 15).toFixed(0) },
- },
- });
- }
-
- // ========================================================================
- // 3. Two Walking — collision avoidance, phone pause, confidence dip at crossing
- // ========================================================================
-
- _twoWalking(t) {
- const n = _getNoiseBank('two_walking');
-
- // Person 1: walks a figure-8 with speed variation
- const p1speed = 0.5 + _harmonicNoise(t, 1.1, 2) * 0.1; // natural speed var
- const p1phase = t * p1speed;
- let p1x = Math.sin(p1phase) * 2.5;
- let p1z = Math.sin(p1phase * 0.7) * Math.cos(p1phase * 0.35) * 1.8;
-
- // Person 2: walks an ellipse, pauses at t~10-12 (checking phone)
- const phonePause = (t >= 10 && t < 12);
- const p2speedMod = phonePause ? 0.05 : 1.0; // nearly stopped during phone check
- const p2speed = (0.4 + _harmonicNoise(t, 2.2, 2) * 0.08) * p2speedMod;
- const p2phase = t * 0.4 + 1 + (phonePause ? 0 : _harmonicNoise(t, 3.3, 2) * 0.1);
- let p2x = -Math.sin(p2phase) * 2;
- let p2z = Math.cos(p2phase * 0.75 + 2) * 1.5;
-
- // Collision avoidance: repulsion when persons are close
- const dx = p1x - p2x;
- const dz = p1z - p2z;
- const dist = Math.sqrt(dx * dx + dz * dz);
- const minDist = 0.8;
- if (dist < minDist * 3 && dist > 0.01) {
- const repulsion = Math.max(0, 1 - dist / (minDist * 3)) * 0.6;
- const nx = dx / dist, nz = dz / dist;
- p1x += nx * repulsion;
- p1z += nz * repulsion;
- p2x -= nx * repulsion;
- p2z -= nz * repulsion;
- }
-
- // Confidence dip when persons are close (tracking confusion)
- const proxConfidence = dist < 1.5 ? 0.65 + dist * 0.1 : 0.82;
- const matchConfidence = dist < 1.2 ? 0.6 + dist * 0.2 : 0.91;
-
- const p1facing = Math.atan2(
- Math.cos(p1phase) * p1speed * 2.5,
- Math.cos(p1phase * 0.7) * 0.7 * Math.cos(p1phase * 0.35) * 1.8
- );
- const p2facing = phonePause
- ? Math.PI * 0.8 // looking down at phone
- : Math.atan2(-Math.cos(p2phase) * p2speed * 2, -Math.sin(p2phase * 0.75 + 2) * 0.75 * 1.5);
-
- const p1ms = 160 + _harmonicNoise(t, 4.4, 2) * 20;
- const p2ms = phonePause ? 8 + Math.abs(n.motion(t)) * 5 : 140 + _harmonicNoise(t, 5.5, 2) * 20;
-
- const amplitude = new Float32Array(64);
- for (let i = 0; i < 64; i++) {
- amplitude[i] = 0.3 + 0.3 * Math.abs(Math.sin(t * 2 + i * 0.3))
- + _harmonicNoise(t, i * 0.17, 2) * 0.02;
- }
-
- return this._baseFrame({
- nodes: [{ node_id: 1, rssi_dbm: -40 + Math.sin(t * 1.2) * 4 + n.rssi(t), position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }],
- features: {
- mean_rssi: -40 + Math.sin(t * 1.2) * 4 + n.rssi(t),
- variance: 3.5 + Math.sin(t * 0.8) * 1.2 + (dist < 1.5 ? 2 : 0),
- std: 1.87 + (dist < 1.5 ? 0.5 : 0),
- motion_band_power: 0.25 + Math.abs(Math.sin(t * 1.5)) * 0.15 * (phonePause ? 0.3 : 1),
- breathing_band_power: 0.06,
- dominant_freq_hz: 1.2 + Math.sin(t * 0.5) * 0.3,
- spectral_power: 0.45,
- },
- classification: { motion_level: phonePause ? 'present_still' : 'active', presence: true, confidence: proxConfidence },
- signal_field: { grid_size: [20, 1, 20], values: this._twoPresenceField(10 + p1x * 2, 10 + p1z * 2, 10 + p2x * 2, 10 + p2z * 2, t) },
- vital_signs: { breathing_rate_bpm: 18, heart_rate_bpm: 85, breathing_confidence: 0.4, heart_rate_confidence: 0.35 },
- persons: [
- { id: 'p0', position: [p1x, 0, p1z], motion_score: p1ms, pose: 'walking', facing: p1facing },
- { id: 'p1', position: [p2x, 0, p2z], motion_score: p2ms, pose: phonePause ? 'standing' : 'walking', facing: p2facing },
- ],
- estimated_persons: 2,
- edge_modules: {
- person_match: { matched: 2, confidence: matchConfidence, proximity_warning: dist < 1.5 },
- tracking: { id_swap_risk: dist < 1.0, nearest_pair_dist: dist.toFixed(2) },
- },
- });
- }
-
- // ========================================================================
- // 4. Fall Event — pre-fall stumble, impact spike, micro-movements, shock HR
- // ========================================================================
-
- _fallEvent(t) {
- const n = _getNoiseBank('fall_event');
-
- // Timeline: 0-3 normal walk, 3-5 stumble, 5-5.8 fall, 5.8-8 micro-movement, 8+ still
- const stumbleStart = 3, fallStart = 5, fallEnd = 5.8;
- const microEnd = 8, stillPhase = t >= microEnd;
-
- const preStumble = t < stumbleStart;
- const stumbling = t >= stumbleStart && t < fallStart;
- const inFall = t >= fallStart && t < fallEnd;
- const microMovement = t >= fallEnd && t < microEnd;
- const postFall = t >= fallEnd;
-
- // Pre-fall stumble: unsteady gait (asymmetric, wobbly)
- const stumbleIntensity = stumbling ? _smoothstep(stumbleStart, fallStart, t) : 0;
- const wobble = stumbling ? Math.sin(t * 8) * stumbleIntensity * 0.4 : 0;
-
- // Fall impact spike: sharp gaussian at moment of impact
- const impactT = (fallStart + fallEnd) / 2;
- const impactSpike = Math.exp(-((t - impactT) ** 2) / 0.04) * 1.0;
-
- // Post-fall micro-movements (trying to get up)
- const microIntensity = microMovement
- ? (1 - _smoothstep(fallEnd, microEnd, t)) * 0.3
- : 0;
- const microSignal = microMovement
- ? Math.sin(t * 3) * microIntensity + Math.sin(t * 5.5) * microIntensity * 0.4
- : 0;
-
- // Heart rate: normal 72, elevated post-fall shock response 100-110 BPM
- let hrRate = 72;
- if (stumbling) hrRate = 72 + stumbleIntensity * 15; // anxiety rising
- else if (inFall) hrRate = 90 + impactSpike * 30;
- else if (postFall) hrRate = 108 - _smoothstep(fallEnd, fallEnd + 20, t) * 30; // slowly comes down
- hrRate += n.hr(t) * 1.5;
-
- // Breathing: elevated post-fall
- let breathRate = 16;
- if (postFall) breathRate = 24 - _smoothstep(fallEnd, fallEnd + 15, t) * 8;
- breathRate += n.breath(t) * 0.5;
-
- // Position: walking -> stumble -> fall -> ground
- let px = 0.3, pz = 0.2, py = 0, pose = 'standing', ms = 20;
- if (preStumble) {
- px = Math.sin(t * 0.4) * 1.5;
- pz = t * 0.3 - 1;
- pose = 'walking';
- ms = 80;
- } else if (stumbling) {
- const st = (t - stumbleStart) / (fallStart - stumbleStart);
- px = Math.sin(stumbleStart * 0.4) * 1.5 + wobble + st * 0.5;
- pz = (stumbleStart * 0.3 - 1) + st * 0.3;
- pose = 'walking'; // stumbling but still upright
- ms = 120 + stumbleIntensity * 80;
- } else if (inFall) {
- pose = 'falling';
- ms = 255;
- py = 0;
- } else if (microMovement) {
- pose = 'fallen';
- ms = _clamp(microIntensity * 100, 3, 40);
- px += microSignal * 0.1;
- } else {
- pose = 'fallen';
- ms = 2 + Math.abs(n.motion(t)) * 1;
- }
-
- const motionPower = preStumble ? 0.08
- : stumbling ? 0.15 + stumbleIntensity * 0.3
- : inFall ? 0.6 + impactSpike * 0.4
- : microMovement ? 0.05 + microIntensity * 0.15
- : 0.02 + Math.abs(n.motion(t)) * 0.005;
-
- const amplitude = new Float32Array(64);
- for (let i = 0; i < 64; i++) {
- const base = postFall && !microMovement ? 0.15 : 0.3;
- amplitude[i] = base + impactSpike * 0.5 + microSignal * 0.1
- + Math.sin(t * 0.5 + i * 0.1) * 0.1 * (1 - (stillPhase ? 0.7 : 0))
- + _harmonicNoise(t, i * 0.19, 2) * 0.01;
- }
-
- const rssi = -43 + impactSpike * 8 + wobble * 2 + n.rssi(t) * 0.8;
-
- return this._baseFrame({
- nodes: [{ node_id: 1, rssi_dbm: rssi, position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }],
- features: {
- mean_rssi: rssi,
- variance: postFall && !microMovement ? 0.5 : (1.5 + impactSpike * 6 + stumbleIntensity * 2),
- std: postFall && !microMovement ? 0.7 : (1.22 + impactSpike * 2),
- motion_band_power: motionPower,
- breathing_band_power: postFall ? 0.08 + Math.abs(n.breath(t)) * 0.02 : 0.1,
- dominant_freq_hz: inFall ? 3.5 : (stumbling ? 1.8 + wobble : 0.15),
- spectral_power: inFall ? 0.9 : (postFall ? 0.1 : 0.2 + stumbleIntensity * 0.3),
- },
- classification: {
- motion_level: postFall && !microMovement ? 'present_still' : (inFall || stumbling ? 'active' : 'present_still'),
- presence: true,
- confidence: inFall ? 0.55 : (stumbling ? 0.7 : (postFall ? 0.6 : 0.85)),
- fall_detected: inFall || postFall,
- pre_fall_warning: stumbling,
- },
- signal_field: { grid_size: [20, 1, 20], values: this._presenceField(10 + px, 10 + pz, postFall ? 1.5 + microIntensity : 2.5, t) },
- vital_signs: {
- breathing_rate_bpm: breathRate,
- heart_rate_bpm: hrRate,
- breathing_confidence: postFall ? 0.5 + _smoothstep(fallEnd, fallEnd + 5, t) * 0.2 : 0.8,
- heart_rate_confidence: postFall ? 0.4 + _smoothstep(fallEnd, fallEnd + 5, t) * 0.2 : 0.7,
- },
- persons: [{ id: 'p0', position: [px, py, pz], motion_score: ms, pose, facing: 0.5, fallProgress: inFall ? (t - fallStart) / (fallEnd - fallStart) : (postFall ? 1 : 0) }],
- estimated_persons: 1,
- edge_modules: {
- fall_detect: {
- detected: inFall || postFall,
- severity: inFall ? 'critical' : (microMovement ? 'monitoring_movement' : (stillPhase ? 'monitoring_still' : 'none')),
- impact_time: postFall ? (fallEnd - fallStart).toFixed(2) : (inFall ? (t - fallStart).toFixed(2) : '0'),
- pre_fall_stumble: stumbling,
- post_fall_movement: microMovement,
- shock_hr_bpm: postFall ? hrRate.toFixed(0) : null,
- },
- },
- });
- }
-
- // ========================================================================
- // 5. Sleep Monitoring — sleep stages, REM, position changes, apnea buildup
- // ========================================================================
-
- _sleepMonitoring(t) {
- const n = _getNoiseBank('sleep_monitoring');
-
- // Sleep stages timeline (30s cycle compressed):
- // 0-4: light sleep (stage 1-2)
- // 4-10: deep sleep (stage 3-4)
- // 10-14: REM sleep
- // 14-16: position change
- // 16-18: light sleep again
- // 18-22: apnea warning signs (breathing gets irregular)
- // 22-26: apnea event
- // 26-30: recovery
- const cycleT = t % 30;
-
- let sleepStage = 'light';
- let breathRateBase = 14;
- let movementLevel = 0.03;
- let hrBase = 64;
- let eyeMovementArtifact = 0;
- let positionChangeActive = false;
-
- if (cycleT < 4) {
- // Light sleep: more body movement, higher breath rate
- sleepStage = 'light';
- breathRateBase = 14 + _harmonicNoise(t, 1.1, 2) * 1.5;
- movementLevel = 0.06 + Math.abs(n.motion(t)) * 0.03;
- hrBase = 64 + _harmonicNoise(t, 2.2, 2) * 3;
- } else if (cycleT < 10) {
- // Deep sleep: minimal movement, slow breathing, low HR
- sleepStage = 'deep';
- breathRateBase = 10 + _harmonicNoise(t, 1.3, 2) * 0.5;
- movementLevel = 0.01;
- hrBase = 56 + _harmonicNoise(t, 2.4, 2) * 1;
- } else if (cycleT < 14) {
- // REM: rapid eye movement creates signal artifacts, HR more variable
- sleepStage = 'REM';
- breathRateBase = 16 + _harmonicNoise(t, 1.5, 2) * 2;
- movementLevel = 0.02;
- hrBase = 68 + _harmonicNoise(t, 2.6, 3) * 5; // more variable in REM
- // Eye movement artifact: high-frequency bursts
- const remBurst = Math.sin(t * 12) * Math.sin(t * 7.3) * 0.5;
- eyeMovementArtifact = Math.max(0, remBurst) * 0.08;
- } else if (cycleT < 16) {
- // Position change: brief movement spike
- sleepStage = 'light';
- positionChangeActive = true;
- const changeProgress = (cycleT - 14) / 2;
- movementLevel = changeProgress < 0.5
- ? _smoothstep(0, 0.5, changeProgress) * 0.5
- : _smoothstep(1, 0.5, changeProgress) * 0.5;
- breathRateBase = 16;
- hrBase = 68;
- } else if (cycleT < 18) {
- sleepStage = 'light';
- breathRateBase = 13;
- movementLevel = 0.04;
- hrBase = 62;
- } else if (cycleT < 22) {
- // Pre-apnea: breathing becomes irregular
- sleepStage = 'light';
- const irregularity = _smoothstep(18, 22, cycleT);
- breathRateBase = 12 - irregularity * 6; // slowing down
- // Breathing becomes chaotic before stopping
- const chaotic = irregularity * Math.sin(t * 3 + Math.sin(t * 1.7) * 2) * 0.4;
- breathRateBase = Math.max(3, breathRateBase + chaotic * 5);
- movementLevel = 0.02;
- hrBase = 60 - irregularity * 4;
- } else if (cycleT < 26) {
- // Full apnea
- sleepStage = 'apnea';
- breathRateBase = 0 + Math.abs(n.breath(t)) * 0.5; // near-zero
- movementLevel = 0.01;
- hrBase = 54 + _smoothstep(22, 26, cycleT) * 8; // HR rises during apnea (stress)
- } else {
- // Recovery: gasp, then return to normal
- sleepStage = 'light';
- const recovery = _smoothstep(26, 28, cycleT);
- breathRateBase = 6 + recovery * 10; // gasping then normalizing
- movementLevel = cycleT < 27 ? 0.15 : 0.04; // body startles
- hrBase = 70 - recovery * 6;
- }
-
- const inApnea = sleepStage === 'apnea';
- const breathFreq = breathRateBase / 60;
- const breathPhase = Math.sin(2 * Math.PI * breathFreq * t + n.breath(t) * 0.3);
- const breathSignal = inApnea ? n.breath(t) * 0.05 : breathPhase;
-
- // Lying position: slight shifts over time, bigger shift during position change
- const posAngle = positionChangeActive
- ? Math.PI / 2 + _smoothstep(14, 16, cycleT) * Math.PI * 0.3
- : Math.PI / 2 + Math.sin(t * 0.02) * 0.1;
- const lyingX = 3.5 + (positionChangeActive ? Math.sin(cycleT * 2) * 0.3 : 0);
-
- const amplitude = new Float32Array(64);
- for (let i = 0; i < 64; i++) {
- const base = 0.25 + breathSignal * 0.04 * (1 - (inApnea ? 0.9 : 0));
- const rem = eyeMovementArtifact * (i > 30 && i < 50 ? 1.5 : 0.3); // REM artifact in upper band
- amplitude[i] = base + rem + movementLevel * Math.sin(t * 0.8 + i * 0.1)
- + _harmonicNoise(t, i * 0.11, 2) * 0.005;
- }
-
- return this._baseFrame({
- nodes: [{ node_id: 1, rssi_dbm: -44 + breathSignal * 0.5 + n.rssi(t) * 0.3, position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }],
- features: {
- mean_rssi: -44 + breathSignal * 0.5 + n.rssi(t) * 0.3,
- variance: inApnea ? 0.15 : (0.6 + movementLevel * 3),
- std: inApnea ? 0.39 : (0.77 + movementLevel),
- motion_band_power: movementLevel + eyeMovementArtifact * 0.5,
- breathing_band_power: inApnea ? 0.02 : (0.1 + Math.abs(breathSignal) * 0.05),
- dominant_freq_hz: breathFreq,
- spectral_power: 0.08 + eyeMovementArtifact * 0.3,
- },
- classification: { motion_level: movementLevel > 0.1 ? 'active' : 'present_still', presence: true, confidence: 0.9, apnea_detected: inApnea },
- signal_field: { grid_size: [20, 1, 20], values: this._presenceField(15, 13, 1.8 + movementLevel * 2, t) },
- vital_signs: {
- breathing_rate_bpm: breathRateBase,
- heart_rate_bpm: hrBase + n.hr(t) * 1,
- breathing_confidence: inApnea ? 0.35 : (0.85 + breathSignal * 0.05),
- heart_rate_confidence: 0.82,
- },
- persons: [{ id: 'p0', position: [lyingX, 0.45, -3.5 + n.pos1(t) * 0.1], motion_score: _clamp(movementLevel * 100, 1, 50), pose: 'lying', facing: posAngle }],
- estimated_persons: 1,
- edge_modules: {
- sleep_staging: {
- stage: sleepStage,
- stage_duration_s: cycleT.toFixed(1),
- position_change: positionChangeActive,
- rem_density: eyeMovementArtifact > 0.02 ? 'high' : 'low',
- },
- sleep_apnea: {
- state: inApnea ? 'apnea_event' : (cycleT >= 18 && cycleT < 22 ? 'pre_apnea_warning' : 'normal'),
- duration_s: inApnea ? (cycleT - 22).toFixed(1) : 0,
- events_total: inApnea ? 1 : 0,
- breathing_irregularity: cycleT >= 18 && cycleT < 22 ? _smoothstep(18, 22, cycleT).toFixed(2) : '0',
- },
- cardiac_arrhythmia: { rhythm: 'sinus', hr_variability: (4.2 + _harmonicNoise(t, 8.8, 2) * 2).toFixed(1) },
- },
- });
- }
-
- // ========================================================================
- // 6. Intrusion Detection — door pressure, cautious movement, drawer search
- // ========================================================================
-
- _intrusionDetect(t) {
- const n = _getNoiseBank('intrusion_detect');
-
- // Timeline:
- // 0-2: baseline (quiet room)
- // 2-3: door opens (pressure change, environmental shift)
- // 3-6: cautious entry (pause-move-pause)
- // 6-10: checking corners
- // 10-14: checks room, pauses
- // 14-22: searches drawers near desk (oscillating position)
- // 22+: settles, loiters
-
- const doorOpen = t >= 2 && t < 3;
- const entered = t >= 3;
- const cautiousEntry = t >= 3 && t < 6;
- const checkingCorners = t >= 6 && t < 10;
- const settledSearch = t >= 14 && t < 22;
- const loitering = t >= 22;
-
- // Environmental baseline shift when door opens
- const doorPressure = doorOpen ? Math.sin((t - 2) * Math.PI) * 0.4 : 0;
-
- // Cautious movement: pause-move-pause pattern
- let px, pz, facing, ms, pose;
- if (!entered) {
- px = -5.5; pz = -2; facing = 0; ms = 0; pose = 'absent';
- } else if (cautiousEntry) {
- // Pause-move-pause pattern
- const entryT = t - 3;
- const movePhase = entryT % 1.5;
- const isMoving = movePhase > 0.6 && movePhase < 1.3; // move for 0.7s, pause for 0.8s
- const progress = Math.min(1, entryT / 3);
- px = -4.5 + progress * 3;
- pz = -1 + progress * 0.8;
- // Slight position jitter during pauses (looking around)
- if (!isMoving) {
- px += Math.sin(t * 4) * 0.05;
- facing = Math.sin(t * 2) * 0.5 + 0.8; // head scanning
- } else {
- facing = Math.atan2(3, 0.8); // heading into room
- }
- ms = isMoving ? 100 : 8;
- pose = 'crouching';
- } else if (checkingCorners) {
- // Move to corners, pause at each
- const cornerT = (t - 6) / 4;
- const cornerIdx = Math.floor(cornerT * 3) % 3;
- const corners = [[-2, -0.5], [0, 1], [2, 0]];
- const corner = corners[cornerIdx];
- const inTransit = (cornerT * 3) % 1 < 0.6;
- px = _lerp(corner[0], corners[(cornerIdx + 1) % 3][0], inTransit ? (cornerT * 3 % 1) / 0.6 : 0);
- pz = _lerp(corner[1], corners[(cornerIdx + 1) % 3][1], inTransit ? (cornerT * 3 % 1) / 0.6 : 0);
- facing = inTransit ? Math.atan2(corners[(cornerIdx + 1) % 3][0] - corner[0], corners[(cornerIdx + 1) % 3][1] - corner[1]) : Math.sin(t * 3) * Math.PI; // scanning while paused
- ms = inTransit ? 120 : 10;
- pose = 'crouching';
- } else if (settledSearch) {
- // Oscillating near desk area, opening drawers
- const searchT = t - 14;
- const deskX = 1.5, deskZ = -0.5;
- px = deskX + Math.sin(searchT * 1.2) * 0.6; // back and forth along desk
- pz = deskZ + Math.cos(searchT * 0.8) * 0.3;
- // Periodic reaching motion (drawer open/close every ~2s)
- const reaching = Math.sin(searchT * Math.PI) > 0.7;
- facing = reaching ? 0 : Math.PI * 0.5;
- ms = reaching ? 80 : 30;
- pose = reaching ? 'reaching' : 'standing';
- } else if (loitering) {
- px = 0.5 + n.pos1(t) * 0.2;
- pz = 0.5 + n.pos2(t) * 0.2;
- facing = Math.sin(t * 0.3) * Math.PI;
- ms = 12 + Math.abs(n.motion(t)) * 8;
- pose = 'standing';
- } else {
- // 10-14: general room check
- const checkT = (t - 10) / 4;
- px = -1 + Math.sin(checkT * Math.PI * 2) * 2;
- pz = Math.cos(checkT * Math.PI * 2) * 1.5;
- facing = Math.atan2(Math.cos(checkT * Math.PI * 2) * 2, -Math.sin(checkT * Math.PI * 2) * 1.5);
- ms = 90;
- pose = 'walking';
- }
-
- const isMovingNow = ms > 50;
- const amplitude = new Float32Array(64);
- for (let i = 0; i < 64; i++) {
- amplitude[i] = entered
- ? 0.35 + 0.15 * Math.sin(t * 1.5 + i * 0.2) + _harmonicNoise(t, i * 0.14, 2) * 0.01
- : 0.1 + doorPressure * 0.05 + _harmonicNoise(t, i * 0.14, 2) * 0.008;
- }
-
- const rssiBase = entered ? -38 + Math.sin(t * 2) * 3 + n.rssi(t) : -46 + doorPressure * 2 + n.rssi(t) * 0.3;
-
- return this._baseFrame({
- nodes: [{ node_id: 1, rssi_dbm: rssiBase, position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }],
- features: {
- mean_rssi: rssiBase,
- variance: isMovingNow ? 4.5 : (entered ? 1.0 : 0.2 + Math.abs(doorPressure) * 1.5),
- std: isMovingNow ? 2.1 : 0.45,
- motion_band_power: isMovingNow ? 0.4 : (entered ? 0.03 + (settledSearch ? 0.08 : 0) : 0.01 + Math.abs(doorPressure) * 0.15),
- breathing_band_power: entered && !isMovingNow ? 0.08 : 0.01,
- dominant_freq_hz: isMovingNow ? 1.8 : (doorOpen ? 0.5 : 0.1),
- spectral_power: isMovingNow ? 0.55 : 0.03 + Math.abs(doorPressure) * 0.2,
- },
- classification: {
- motion_level: isMovingNow ? 'active' : (entered ? 'present_still' : 'absent'),
- presence: entered || doorOpen,
- confidence: entered ? 0.78 + (loitering ? 0.1 : 0) : (doorOpen ? 0.45 : 0.95),
- intrusion: entered,
- perimeter_breach: t >= 3 && t < 5,
- door_event: doorOpen,
- },
- signal_field: { grid_size: [20, 1, 20], values: entered ? this._presenceField(10 + px, 10 + pz, 2, t) : this._flatField(0.04 + Math.abs(doorPressure) * 0.06) },
- vital_signs: {
- breathing_rate_bpm: entered && !isMovingNow ? 20 + n.breath(t) : 0,
- heart_rate_bpm: entered ? 90 + _harmonicNoise(t, 4.4, 2) * 5 : 0, // elevated from adrenaline
- breathing_confidence: entered && !isMovingNow ? 0.6 : 0,
- heart_rate_confidence: entered && !isMovingNow ? 0.4 : 0,
- },
- persons: entered ? [{ id: 'p0', position: [px, 0, pz], motion_score: ms, pose, facing }] : [],
- estimated_persons: entered ? 1 : 0,
- edge_modules: {
- intrusion: {
- detected: entered,
- zone: cautiousEntry ? 'perimeter' : (settledSearch ? 'desk_area' : 'interior'),
- threat_level: cautiousEntry ? 'high' : (settledSearch ? 'high' : (loitering ? 'medium' : 'none')),
- behavior_pattern: cautiousEntry ? 'cautious_entry' : (checkingCorners ? 'corner_check' : (settledSearch ? 'searching' : (loitering ? 'loitering' : 'none'))),
- },
- loitering: { detected: loitering || settledSearch, duration_s: loitering ? (t - 22).toFixed(1) : (settledSearch ? (t - 14).toFixed(1) : 0) },
- door_sensor: { open_event: doorOpen, pressure_delta: doorPressure.toFixed(3) },
- },
- });
- }
-
- // ========================================================================
- // 7. Gesture Control — distinct gesture signatures, recognition feedback
- // ========================================================================
-
- _gestureControl(t) {
- const n = _getNoiseBank('gesture_control');
-
- const gestureCycle = 7; // seconds per gesture
- const gesturePhase = Math.floor(t / gestureCycle) % 4;
- const gestures = ['wave', 'swipe_left', 'circle', 'point'];
- const gestureT = t % gestureCycle;
- const isGesturing = gestureT >= 1.5 && gestureT < 5;
- const gestureProgress = isGesturing ? (gestureT - 1.5) / 3.5 : 0;
- const gestureEnvelope = isGesturing ? Math.sin(gestureProgress * Math.PI) : 0;
-
- // Recognition feedback: brief confidence spike when gesture completes (at ~80% progress)
- const recognitionMoment = isGesturing && gestureProgress > 0.75 && gestureProgress < 0.85;
- const recognitionBoost = recognitionMoment ? 0.15 : 0;
-
- // Gesture-specific signal characteristics
- let gestureSignal = 0;
- let dominantFreq = 0.2;
- let motionScore = 10;
- let gestureDetail = {};
-
- const g = gestures[gesturePhase];
- if (isGesturing) {
- switch (g) {
- case 'wave':
- // Fast oscillation (hand waving back and forth)
- gestureSignal = Math.sin(t * 14) * gestureEnvelope * 0.5
- + Math.sin(t * 21) * gestureEnvelope * 0.2; // harmonics
- dominantFreq = 4.0 + _harmonicNoise(t, 6.6, 2) * 0.3;
- motionScore = 150 * gestureEnvelope;
- gestureDetail = { oscillation_hz: 7, amplitude: gestureEnvelope.toFixed(2) };
- break;
- case 'swipe_left':
- // Clear directional shift: signal ramps in one direction
- gestureSignal = (gestureProgress - 0.5) * 2 * gestureEnvelope * 0.6;
- dominantFreq = 2.0;
- motionScore = 180 * gestureEnvelope;
- gestureDetail = { direction: 'left', displacement: gestureSignal.toFixed(3) };
- break;
- case 'circle':
- // Rotating phase pattern
- const circleAngle = gestureProgress * Math.PI * 2 * 1.5; // 1.5 rotations
- gestureSignal = Math.sin(circleAngle) * gestureEnvelope * 0.4;
- const phaseRotation = Math.cos(circleAngle) * gestureEnvelope * 0.4;
- dominantFreq = 3.0;
- motionScore = 130 * gestureEnvelope;
- gestureDetail = { rotation_angle: (circleAngle * 180 / Math.PI).toFixed(0), phase_i: gestureSignal.toFixed(3), phase_q: phaseRotation.toFixed(3) };
- break;
- case 'point':
- // Quick, decisive: sharp onset, brief hold, sharp offset
- const pointEnvelope = gestureProgress < 0.2
- ? _smoothstep(0, 0.2, gestureProgress) // fast rise
- : (gestureProgress < 0.6 ? 1.0 : _smoothstep(1, 0.6, gestureProgress)); // hold then drop
- gestureSignal = pointEnvelope * 0.55;
- dominantFreq = 1.5; // lower freq, more impulse-like
- motionScore = 200 * pointEnvelope;
- gestureDetail = { sharpness: pointEnvelope > 0.9 ? 'locked' : 'transitioning' };
- break;
- }
- }
-
- const amplitude = new Float32Array(64);
- for (let i = 0; i < 64; i++) {
- const base = 0.3 + _harmonicNoise(t, i * 0.13, 2) * 0.01;
- // Each gesture affects subcarriers differently
- let gestMod = 0;
- if (isGesturing) {
- if (g === 'wave') gestMod = Math.sin(t * 14 + i * 0.5) * gestureEnvelope * 0.15;
- else if (g === 'swipe_left') gestMod = gestureSignal * (i / 64) * 0.2; // gradient across band
- else if (g === 'circle') gestMod = Math.sin(t * 8 + i * 0.3) * gestureEnvelope * 0.12;
- else if (g === 'point') gestMod = gestureSignal * 0.2 * (i > 25 && i < 40 ? 1.5 : 0.5);
- }
- amplitude[i] = base + gestMod;
- }
-
- const rssi = -41 + gestureEnvelope * 3 + n.rssi(t) * 0.5;
- const confidence = isGesturing ? 0.7 + gestureEnvelope * 0.15 + recognitionBoost : 0;
-
- return this._baseFrame({
- nodes: [{ node_id: 1, rssi_dbm: rssi, position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }],
- features: {
- mean_rssi: rssi,
- variance: 1.2 + gestureEnvelope * 2.5,
- std: 1.1 + gestureEnvelope * 0.5,
- motion_band_power: 0.05 + Math.abs(gestureSignal) * 0.6,
- breathing_band_power: 0.08,
- dominant_freq_hz: dominantFreq,
- spectral_power: 0.15 + gestureEnvelope * 0.4,
- },
- classification: {
- motion_level: isGesturing ? 'active' : 'present_still',
- presence: true,
- confidence: 0.85,
- gesture: isGesturing ? g : null,
- gesture_recognized: recognitionMoment,
- },
- signal_field: { grid_size: [20, 1, 20], values: this._presenceField(10, 10, 2 + gestureEnvelope * 0.8, t) },
- vital_signs: { breathing_rate_bpm: 16 + n.breath(t) * 0.5, heart_rate_bpm: 74 + n.hr(t) * 1, breathing_confidence: 0.7, heart_rate_confidence: 0.65 },
- persons: [{ id: 'p0', position: [0 + n.pos1(t) * 0.03, 0, 0.5 + n.pos2(t) * 0.03], motion_score: motionScore, pose: 'gesturing', facing: Math.PI, gestureType: g, gestureIntensity: gestureEnvelope, gestureDetail }],
- estimated_persons: 1,
- edge_modules: {
- gesture: {
- detected: isGesturing,
- type: isGesturing ? g : 'none',
- confidence: confidence,
- dtw_distance: isGesturing ? (12.3 - recognitionBoost * 8) : 999,
- recognition_feedback: recognitionMoment ? 'MATCHED' : null,
- detail: gestureDetail,
- },
- },
- });
- }
-
- // ========================================================================
- // 8. Crowd Occupancy — clustering, stationary person, rushing, entry/exit
- // ========================================================================
-
- _crowdOccupancy(t) {
- const n = _getNoiseBank('crowd_occupancy');
-
- // Points of interest for clustering
- const poi = [
- { x: -2, z: -1.5, label: 'display' }, // display/kiosk
- { x: 2, z: 1, label: 'counter' }, // service counter
- { x: 0, z: 0, label: 'center' }, // open area
- ];
-
- // 5 people with distinct behaviors
- const persons = [];
- const count = 5;
-
- // Person 0: sits stationary at desk
- {
- const px = -1.5 + n.pos1(t) * 0.02;
- const pz = 1.5 + n.pos2(t) * 0.02;
- persons.push({ id: 'p0', position: [px, 0, pz], motion_score: 3, pose: 'sitting', facing: Math.PI * 0.5 + _harmonicNoise(t, 11.1, 2) * 0.1 });
- }
-
- // Person 1: browses near display (clusters near POI 0)
- {
- const browseT = t * 0.3;
- const px = poi[0].x + Math.sin(browseT) * 0.8 + _harmonicNoise(t, 12.1, 2) * 0.1;
- const pz = poi[0].z + Math.cos(browseT * 0.7) * 0.6;
- const facing = Math.atan2(poi[0].x - px, poi[0].z - pz);
- persons.push({ id: 'p1', position: [px, 0, pz], motion_score: 40 + Math.abs(_harmonicNoise(t, 13.1, 2)) * 20, pose: 'walking', facing });
- }
-
- // Person 2: rushes through (faster speed, enters and exits)
- {
- const rushCycle = 20;
- const rushT = t % rushCycle;
- const inSpace = rushT > 2 && rushT < 15;
- const rushProgress = inSpace ? (rushT - 2) / 13 : 0;
- const rushSpeed = 1.5 + _harmonicNoise(t, 14.1, 2) * 0.2;
- const px = inSpace ? -4 + rushProgress * 8 : -5;
- const pz = inSpace ? -0.5 + Math.sin(rushProgress * Math.PI * 0.5) * 0.8 : -3;
- if (inSpace) {
- persons.push({ id: 'p2', position: [px, 0, pz], motion_score: 220, pose: 'walking', facing: 0.1, speed: rushSpeed });
- }
- }
-
- // Person 3: walks between display and counter (clusters near POIs)
- {
- const walkT = t * 0.15;
- const poiIdx = Math.floor(walkT) % 2;
- const target = poiIdx === 0 ? poi[0] : poi[1];
- const progress = walkT % 1;
- const other = poiIdx === 0 ? poi[1] : poi[0];
- const px = _lerp(other.x, target.x, _smoothstep(0, 0.7, progress))
- + _harmonicNoise(t, 15.1, 2) * 0.15;
- const pz = _lerp(other.z, target.z, _smoothstep(0, 0.7, progress))
- + _harmonicNoise(t, 16.1, 2) * 0.15;
- const facing = Math.atan2(target.x - other.x, target.z - other.z);
- const nearPoi = progress > 0.7;
- persons.push({ id: 'p3', position: [px, 0, pz], motion_score: nearPoi ? 15 : 100, pose: nearPoi ? 'standing' : 'walking', facing });
- }
-
- // Person 4: enters/exits periodically
- {
- const cycleLen = 25;
- const ct = t % cycleLen;
- const entering = ct < 3;
- const inside = ct >= 3 && ct < 18;
- const exiting = ct >= 18 && ct < 21;
- if (entering || inside || exiting) {
- let px, pz;
- if (entering) {
- px = -4.5 + (ct / 3) * 3;
- pz = -2 + (ct / 3) * 1;
- } else if (exiting) {
- const ep = (ct - 18) / 3;
- px = 1 + ep * 3;
- pz = 0.5 + ep * 1;
- } else {
- px = poi[2].x + Math.sin(t * 0.2) * 1.5;
- pz = poi[2].z + Math.cos(t * 0.15) * 1;
- }
- persons.push({ id: 'p4', position: [px, 0, pz], motion_score: (entering || exiting) ? 130 : 70, pose: 'walking', facing: entering ? 0.3 : (exiting ? -0.3 : Math.atan2(Math.cos(t * 0.2), -Math.sin(t * 0.15))) });
- }
- }
-
- const actualCount = persons.length;
-
- const amplitude = new Float32Array(64);
- for (let i = 0; i < 64; i++) {
- amplitude[i] = 0.35 + 0.25 * Math.abs(Math.sin(t * 1.5 + i * 0.2))
- + _harmonicNoise(t, i * 0.16, 2) * 0.015;
- }
-
- // Signal field with congestion patterns around POIs
- const vals = [];
- for (let iz = 0; iz < 20; iz++) {
- for (let ix = 0; ix < 20; ix++) {
- let v = 0;
- for (const p of persons) {
- const dx = (ix - 10) / 3 - p.position[0];
- const dz = (iz - 10) / 3 - p.position[2];
- v += _gaussian(Math.sqrt(dx * dx + dz * dz), 0.9) * 0.4;
- }
- // POI congestion haze
- for (const p of poi) {
- const dx = (ix - 10) / 3 - p.x;
- const dz = (iz - 10) / 3 - p.z;
- v += _gaussian(Math.sqrt(dx * dx + dz * dz), 2.0) * 0.08;
- }
- vals.push(_clamp(v + _harmonicNoise(t + ix * 0.3, iz * 0.4, 2) * 0.01, 0, 1));
- }
- }
-
- return this._baseFrame({
- nodes: [{ node_id: 1, rssi_dbm: -37 + Math.sin(t * 0.9) * 5 + n.rssi(t), position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }],
- features: {
- mean_rssi: -37 + Math.sin(t * 0.9) * 5 + n.rssi(t),
- variance: 5.2 + Math.sin(t * 0.6) * 1.5 + actualCount * 0.3,
- std: 2.28 + actualCount * 0.1,
- motion_band_power: 0.25 + actualCount * 0.05,
- breathing_band_power: 0.04,
- dominant_freq_hz: 1.5,
- spectral_power: 0.45 + actualCount * 0.03,
- },
- classification: { motion_level: 'active', presence: true, confidence: 0.76 - (actualCount > 4 ? 0.05 : 0) },
- signal_field: { grid_size: [20, 1, 20], values: vals },
- vital_signs: { breathing_rate_bpm: 0, heart_rate_bpm: 0, breathing_confidence: 0.15, heart_rate_confidence: 0.1 },
- persons,
- estimated_persons: actualCount,
- edge_modules: {
- occupancy: {
- count: actualCount,
- zones: {
- display: persons.filter(p => Math.sqrt((p.position[0] - poi[0].x) ** 2 + (p.position[2] - poi[0].z) ** 2) < 2).length,
- counter: persons.filter(p => Math.sqrt((p.position[0] - poi[1].x) ** 2 + (p.position[2] - poi[1].z) ** 2) < 2).length,
- center: persons.filter(p => Math.sqrt((p.position[0] - poi[2].x) ** 2 + (p.position[2] - poi[2].z) ** 2) < 2).length,
- },
- density: (actualCount / 20).toFixed(2), // per sq meter
- congestion_zones: actualCount > 3 ? ['display'] : [],
- },
- customer_flow: {
- entries: Math.floor(t / 25) + 1,
- exits: Math.floor(t / 25),
- dwell_avg_s: 145 + _harmonicNoise(t, 17.1, 2) * 20,
- },
- },
- });
- }
-
- // ========================================================================
- // 9. Search & Rescue — scanning, false positives, triangulation, gradual lock-on
- // ========================================================================
-
- _searchRescue(t) {
- const n = _getNoiseBank('search_rescue');
-
- // Timeline:
- // 0-4: scanning phase (signal sweeps, no detection)
- // 4-7: first false positive (ghost echo)
- // 7-10: second scan, another brief false positive
- // 10-14: genuine signal detected, gradual lock-on
- // 14-20: confirmed detection, vital extraction (confidence building)
- // 20+: stable monitoring
-
- const scanning = t < 4;
- const falsePos1 = t >= 4 && t < 7;
- const scan2 = t >= 7 && t < 10;
- const falsePos2 = t >= 7 && t < 8.5;
- const genuineDetect = t >= 10;
- const lockingOn = t >= 10 && t < 14;
- const confirmed = t >= 14;
- const stableMonitor = t >= 20;
-
- // Scan sweep effect (nodes cycle through angles)
- const scanAngle = t * 0.8;
-
- // Triangulation: 3 sensor nodes with different signal strengths
- const targetPos = [3.5, 0, 0];
- const nodePositions = [[2, 0, 1.5], [-2, 0, 1.5], [0, 0, -2]];
- const nodes = [];
-
- for (let ni = 0; ni < 3; ni++) {
- const npos = nodePositions[ni];
- const dist = Math.sqrt((npos[0] - targetPos[0]) ** 2 + (npos[2] - targetPos[2]) ** 2);
- const baseSignal = -62 - dist * 3; // signal attenuation with distance
- const scanMod = scanning ? Math.sin(scanAngle + ni * 2.1) * 4 : 0;
- const falseSignal = (falsePos1 && ni === 0) ? Math.sin((t - 4) * 3) * 3 : 0;
- const genuineSignal = genuineDetect ? _smoothstep(10, 14, t) * 5 : 0;
-
- const amp = new Float32Array(64);
- for (let i = 0; i < 64; i++) {
- amp[i] = 0.08 + (genuineDetect ? 0.06 * _smoothstep(10, 16, t) : 0)
- * Math.sin(t * 0.4 + i * 0.15 + ni)
- + _harmonicNoise(t, i * 0.12 + ni * 100, 2) * 0.008;
- }
-
- nodes.push({
- node_id: ni + 1,
- rssi_dbm: baseSignal + scanMod + falseSignal + genuineSignal + n.rssi(t) * 0.5,
- position: npos,
- amplitude: amp,
- subcarrier_count: 64,
- distance_estimate: genuineDetect ? (dist + (1 - _smoothstep(10, 20, t)) * 3).toFixed(2) : null,
- });
- }
-
- // Confidence builds gradually during lock-on
- let confidence;
- if (scanning) confidence = 0.08 + Math.abs(n.motion(t)) * 0.05;
- else if (falsePos1) confidence = 0.25 + Math.sin((t - 4) * 2) * 0.15; // fluctuating
- else if (scan2 && !falsePos2) confidence = 0.1;
- else if (falsePos2) confidence = 0.2 + Math.sin((t - 7) * 3) * 0.1;
- else if (lockingOn) confidence = 0.2 + _smoothstep(10, 14, t) * 0.3;
- else if (confirmed && !stableMonitor) confidence = 0.5 + _smoothstep(14, 20, t) * 0.2;
- else confidence = 0.7 + n.env(t) * 0.03;
-
- // Vital sign extraction: gradual confidence over 10+ seconds after detection
- const vitalConfidence = confirmed ? _smoothstep(14, 25, t) : 0;
- const breathRate = genuineDetect ? 10 + _harmonicNoise(t, 3.3, 2) * 0.5 : 0;
- const breathPhase = Math.sin(2 * Math.PI * (breathRate / 60) * t);
-
- // Detected persons
- let detected = false;
- let triageColor = 'unknown';
- if (falsePos1) { detected = true; triageColor = 'unknown'; }
- else if (falsePos2) { detected = true; triageColor = 'unknown'; }
- else if (genuineDetect) { detected = true; triageColor = confidence > 0.5 ? 'yellow' : 'unknown'; }
-
- const persons = detected && genuineDetect
- ? [{ id: 'p0', position: targetPos, motion_score: 2, pose: 'lying', facing: 0, signal_strength: confidence }]
- : (detected ? [{ id: 'ghost', position: [falsePos1 ? 1 : -1, 0, falsePos2 ? 2 : -1], motion_score: 1, pose: 'unknown', facing: 0 }] : []);
-
- return this._baseFrame({
- nodes,
- features: {
- mean_rssi: nodes[0].rssi_dbm,
- variance: genuineDetect ? 0.4 + breathPhase * 0.1 * vitalConfidence : 0.15,
- std: 0.63,
- motion_band_power: 0.01 + (scanning ? Math.abs(Math.sin(scanAngle)) * 0.02 : 0),
- breathing_band_power: genuineDetect ? 0.05 * vitalConfidence + breathPhase * 0.02 * vitalConfidence : 0.005,
- dominant_freq_hz: genuineDetect && vitalConfidence > 0.3 ? 0.167 : (scanning ? 0.8 : 0.02),
- spectral_power: 0.04 + (scanning ? 0.03 : 0),
- },
- classification: {
- motion_level: genuineDetect ? 'present_still' : 'absent',
- presence: detected,
- confidence,
- through_wall: true,
- triage_color: triageColor,
- false_positive: (falsePos1 || falsePos2) && !genuineDetect,
- scan_phase: scanning ? 'sweeping' : (lockingOn ? 'locking_on' : (confirmed ? 'confirmed' : 'searching')),
- },
- signal_field: { grid_size: [20, 1, 20], values: this._searchRescueField(t, scanning, genuineDetect, confidence) },
- vital_signs: {
- breathing_rate_bpm: genuineDetect && vitalConfidence > 0.2 ? breathRate : 0,
- heart_rate_bpm: genuineDetect && vitalConfidence > 0.4 ? 55 + n.hr(t) * 2 : 0,
- breathing_confidence: genuineDetect ? vitalConfidence * 0.85 : 0,
- heart_rate_confidence: genuineDetect ? vitalConfidence * 0.5 : 0,
- },
- persons,
- estimated_persons: detected ? 1 : 0,
- edge_modules: {
- wifi_mat: {
- mode: scanning ? 'scanning' : (lockingOn ? 'locking_on' : (confirmed ? 'monitoring' : 'search')),
- survivors_detected: genuineDetect ? 1 : 0,
- triage: genuineDetect && confidence > 0.5 ? 'delayed' : 'searching',
- signal_through_material: 'concrete_30cm',
- false_positives_filtered: (falsePos1 || falsePos2) ? 1 : 0,
- triangulation_nodes: genuineDetect ? 3 : 0,
- vital_extraction_confidence: (vitalConfidence * 100).toFixed(0) + '%',
- },
- },
- });
- }
-
- // ========================================================================
- // 10. Elderly Care — gait asymmetry, gradual transitions, rest & recover
- // ========================================================================
-
- _elderlyCare(t) {
- const n = _getNoiseBank('elderly_care');
-
- // Timeline:
- // 0-12: walking with gait analysis
- // 12-14: slowing down
- // 14-16: reaching for chair (transitional)
- // 16-18: sitting transition
- // 18-24: resting (HR comes down)
- // 24+: light activity while seated
-
- const walkPhase = t < 12;
- const slowingDown = t >= 12 && t < 14;
- const reachingChair = t >= 14 && t < 16;
- const sittingTransition = t >= 16 && t < 18;
- const resting = t >= 18 && t < 24;
- const seated = t >= 18;
-
- // Walking speed decreases gradually
- const walkSpeed = walkPhase ? 0.6 - _smoothstep(8, 12, t) * 0.2 : (slowingDown ? 0.3 * (1 - _smoothstep(12, 14, t)) : 0);
-
- // Gait analysis: slight asymmetry in step timing (right step ~5% longer)
- const stepFreq = walkPhase ? 1.4 + _harmonicNoise(t, 1.1, 2) * 0.05 : 0;
- const stepPhaseR = Math.sin(2 * Math.PI * stepFreq * t);
- const stepPhaseL = Math.sin(2 * Math.PI * stepFreq * t + Math.PI + 0.15); // asymmetry
- const stepAsymmetry = Math.abs(stepPhaseR) - Math.abs(stepPhaseL);
-
- // Position
- let px, pz, facing, ms, pose;
- if (walkPhase) {
- const wp = t * walkSpeed;
- px = Math.sin(wp * 0.25) * 2;
- pz = Math.cos(wp * 0.15) * 1.2;
- facing = Math.atan2(Math.cos(wp * 0.25) * 0.25 * walkSpeed, -Math.sin(wp * 0.15) * 0.15 * walkSpeed);
- ms = 60 + stepAsymmetry * 10;
- pose = 'walking';
- } else if (slowingDown) {
- const sp = _smoothstep(12, 14, t);
- px = _lerp(Math.sin(12 * 0.6 * 0.25) * 2, 1, sp);
- pz = _lerp(Math.cos(12 * 0.6 * 0.15) * 1.2, -1.5, sp);
- facing = Math.atan2(1 - px, -1.5 - pz);
- ms = 30 * (1 - sp);
- pose = 'walking';
- } else if (reachingChair) {
- const rp = _smoothstep(14, 16, t);
- px = 1 + Math.sin(t * 2) * 0.05 * (1 - rp); // slight unsteadiness reaching
- pz = -1.5;
- facing = Math.PI * 0.25;
- ms = 20 * (1 - rp);
- pose = 'reaching';
- } else if (sittingTransition) {
- const sp = _smoothstep(16, 18, t);
- px = 1;
- pz = -1.5;
- facing = Math.PI * 0.25;
- ms = 15 * (1 - sp);
- pose = sp > 0.5 ? 'sitting' : 'reaching';
- } else {
- px = 1 + n.pos1(t) * 0.02;
- pz = -1.5 + n.pos2(t) * 0.02;
- facing = Math.PI * 0.25 + _harmonicNoise(t, 5.5, 2) * 0.1;
- ms = 5 + Math.abs(n.motion(t)) * 3;
- pose = 'sitting';
- }
-
- // Heart rate: walking ~82, elevated slightly from walking exertion,
- // then gradually comes down during rest (physiological recovery)
- let hrBase;
- if (walkPhase) hrBase = 82 + walkSpeed * 5;
- else if (slowingDown || reachingChair) hrBase = 78 - _smoothstep(12, 16, t) * 5;
- else if (resting) hrBase = 73 - _smoothstep(18, 24, t) * 5; // slow recovery
- else hrBase = 68;
- hrBase += n.hr(t) * 1.5;
-
- // Breathing: correlated with HR
- let breathRate;
- if (walkPhase) breathRate = 18 + walkSpeed * 3;
- else if (seated) breathRate = 14 - _smoothstep(18, 24, t) * 2;
- else breathRate = 16;
- breathRate += n.breath(t) * 0.5;
-
- // Blood pressure proxy: HR/breathing correlation
- const hrBreathRatio = hrBase / breathRate;
-
- const amplitude = new Float32Array(64);
- for (let i = 0; i < 64; i++) {
- amplitude[i] = 0.3 + (walkPhase ? 0.15 : 0.06) * Math.sin(t * 0.8 + i * 0.12)
- + stepAsymmetry * 0.02
- + _harmonicNoise(t, i * 0.11, 2) * 0.008;
- }
-
- return this._baseFrame({
- nodes: [{ node_id: 1, rssi_dbm: -41 + Math.sin(t * 0.5) * 2 + n.rssi(t) * 0.5, position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }],
- features: {
- mean_rssi: -41 + Math.sin(t * 0.5) * 2 + n.rssi(t) * 0.5,
- variance: walkPhase ? 2.2 + stepAsymmetry * 0.5 : 0.8,
- std: walkPhase ? 1.48 : 0.89,
- motion_band_power: walkPhase ? 0.15 + Math.abs(stepPhaseR) * 0.05 : (ms > 10 ? 0.05 : 0.02),
- breathing_band_power: 0.1 + Math.abs(n.breath(t)) * 0.02,
- dominant_freq_hz: walkPhase ? stepFreq * 0.5 : 0.23,
- spectral_power: 0.22,
- },
- classification: { motion_level: walkPhase ? 'active' : (ms > 15 ? 'active' : 'present_still'), presence: true, confidence: 0.88 },
- signal_field: { grid_size: [20, 1, 20], values: this._presenceField(10 + px * 2, 10 + pz * 2, 2.5, t) },
- vital_signs: {
- breathing_rate_bpm: breathRate,
- heart_rate_bpm: hrBase,
- breathing_confidence: 0.82,
- heart_rate_confidence: 0.78,
- hr_breath_ratio: hrBreathRatio.toFixed(2),
- },
- persons: [{ id: 'p0', position: [px, 0, pz], motion_score: ms, pose, facing }],
- estimated_persons: 1,
- edge_modules: {
- gait_analysis: {
- step_frequency: walkPhase ? stepFreq.toFixed(2) : 0,
- stride_length_m: walkPhase ? (0.48 + _harmonicNoise(t, 7.7, 2) * 0.02).toFixed(3) : 0,
- symmetry: walkPhase ? (0.82 + stepAsymmetry * 0.1).toFixed(3) : null,
- asymmetry_side: walkPhase ? 'right_longer' : null,
- fall_risk: walkPhase ? 'low' : 'none',
- },
- vital_trend: {
- status: 'normal',
- hr_trend: resting ? 'recovering' : (walkPhase ? 'elevated' : 'stable'),
- recovery_phase: resting,
- bp_proxy: hrBreathRatio > 5.5 ? 'elevated' : 'normal',
- },
- pattern_sequence: {
- activity: walkPhase ? 'walking' : (reachingChair || sittingTransition ? 'transitioning' : 'resting'),
- transition: reachingChair ? 'reaching_chair' : (sittingTransition ? 'sitting_down' : (slowingDown ? 'slowing' : null)),
- routine_deviation: false,
- },
- },
- });
- }
-
- // ========================================================================
- // 11. Fitness Tracking — warm-up, intensity ramp, rest intervals, HR lag
- // ========================================================================
-
- _fitnessTracking(t) {
- const n = _getNoiseBank('fitness_tracking');
-
- // Timeline:
- // 0-3: warm-up (slow movements, gradually increasing)
- // 3-9: jumping jacks (high intensity)
- // 9-12: rest interval
- // 12-18: squats (medium intensity)
- // 18-21: rest interval
- // 21-27: jumping jacks again (peak intensity)
- // 27-30: cool-down
-
- const block = t % 30;
- let exerciseType = 'rest';
- let targetIntensity = 0; // 0-1 target exertion
- let actualMotion = 0;
-
- if (block < 3) {
- // Warm-up: ramp from 0 to 0.4
- exerciseType = 'warmup';
- targetIntensity = _smoothstep(0, 3, block) * 0.4;
- actualMotion = targetIntensity * 0.8;
- } else if (block < 9) {
- // Jumping jacks
- exerciseType = 'jumping_jacks';
- targetIntensity = 0.7 + _smoothstep(3, 5, block) * 0.2;
- // Rhythmic motion with 2 Hz cadence
- actualMotion = targetIntensity * (0.7 + 0.3 * Math.abs(Math.sin(t * Math.PI * 2)));
- } else if (block < 12) {
- // Rest
- exerciseType = 'rest';
- targetIntensity = 0.1 * (1 - _smoothstep(9, 11, block));
- actualMotion = 0.05 + Math.abs(n.motion(t)) * 0.03; // slight fidgeting
- } else if (block < 18) {
- // Squats: slower, deeper movement
- exerciseType = 'squats';
- targetIntensity = 0.6 + _smoothstep(12, 14, block) * 0.15;
- // Slower cadence (~0.5 Hz), smooth up/down
- const squatPhase = Math.sin(t * Math.PI * 0.5);
- actualMotion = targetIntensity * (0.5 + 0.5 * Math.abs(squatPhase));
- } else if (block < 21) {
- // Rest
- exerciseType = 'rest';
- targetIntensity = 0.1 * (1 - _smoothstep(18, 20, block));
- actualMotion = 0.05;
- } else if (block < 27) {
- // Jumping jacks peak
- exerciseType = 'jumping_jacks';
- targetIntensity = 0.85 + _smoothstep(21, 23, block) * 0.15;
- actualMotion = targetIntensity * (0.7 + 0.3 * Math.abs(Math.sin(t * Math.PI * 2.2)));
- } else {
- // Cool-down
- exerciseType = 'cooldown';
- targetIntensity = 0.3 * (1 - _smoothstep(27, 30, block));
- actualMotion = targetIntensity * 0.5;
- }
-
- // HR lags behind exertion by ~5-8 seconds (physiological delay)
- // Simulate with a slow-tracking variable
- const hrTarget = 70 + targetIntensity * 90; // 70 rest -> 160 max
- // IIR-filtered HR that follows target with delay
- const hrLagFactor = 0.92; // higher = more lag
- const hrDelayed = hrTarget + (70 - hrTarget) * Math.exp(-t * 0.15) * (exerciseType === 'rest' ? 0.5 : 0.2);
- // Use harmonic noise to approximate the lag behavior in a stateless way
- const hrSmooth = hrTarget - _harmonicNoise(t - 3, 8.8, 2) * 5 * targetIntensity;
- const hrRate = _clamp(hrSmooth + n.hr(t) * 2, 60, 185);
-
- // Breathing also lags but less
- const breathTarget = 14 + targetIntensity * 20;
- const breathRate = _clamp(breathTarget + n.breath(t) * 1.5 - _harmonicNoise(t - 1, 9.9, 2) * 2, 12, 40);
-
- const repCount = Math.floor(t * (exerciseType === 'jumping_jacks' ? 1.0 : (exerciseType === 'squats' ? 0.25 : 0)));
-
- // Vertical motion for exercises
- const verticalPos = exerciseType === 'jumping_jacks'
- ? Math.abs(Math.sin(t * Math.PI * 2)) * 0.15
- : (exerciseType === 'squats' ? -Math.abs(Math.sin(t * Math.PI * 0.5)) * 0.3 : 0);
-
- const amplitude = new Float32Array(64);
- for (let i = 0; i < 64; i++) {
- amplitude[i] = 0.35 + 0.3 * actualMotion * Math.abs(Math.sin(t * 2.5 + i * 0.25))
- + _harmonicNoise(t, i * 0.15, 2) * 0.01;
- }
-
- const ms = _clamp(actualMotion * 255, 5, 255);
-
- return this._baseFrame({
- nodes: [{ node_id: 1, rssi_dbm: -39 + actualMotion * 6 + n.rssi(t), position: [2, 0, 1.5], amplitude, subcarrier_count: 64 }],
- features: {
- mean_rssi: -39 + actualMotion * 6 + n.rssi(t),
- variance: 2 + actualMotion * 4,
- std: 1.4 + actualMotion * 1.5,
- motion_band_power: 0.05 + actualMotion * 0.55,
- breathing_band_power: 0.08 + targetIntensity * 0.1,
- dominant_freq_hz: exerciseType === 'jumping_jacks' ? 2.0 : (exerciseType === 'squats' ? 0.5 : 0.2),
- spectral_power: 0.1 + actualMotion * 0.5,
- },
- classification: { motion_level: actualMotion > 0.3 ? 'active' : (actualMotion > 0.1 ? 'present_still' : 'present_still'), presence: true, confidence: 0.8 },
- signal_field: { grid_size: [20, 1, 20], values: this._presenceField(10, 10, 2.5 + actualMotion * 1.5, t) },
- vital_signs: {
- breathing_rate_bpm: breathRate,
- heart_rate_bpm: hrRate,
- breathing_confidence: 0.7,
- heart_rate_confidence: 0.65,
- hr_zone: hrRate > 155 ? 'anaerobic' : (hrRate > 130 ? 'threshold' : (hrRate > 110 ? 'aerobic' : 'warmup')),
- },
- persons: [{
- id: 'p0',
- position: [0 + n.pos1(t) * 0.05, verticalPos, 0 + n.pos2(t) * 0.05],
- motion_score: ms,
- pose: exerciseType === 'rest' || exerciseType === 'cooldown' ? 'standing' : 'exercising',
- facing: 0,
- exerciseType,
- exercisePhase: exerciseType === 'jumping_jacks' ? 'high_cadence' : (exerciseType === 'squats' ? 'controlled' : 'recovery'),
- }],
- estimated_persons: 1,
- edge_modules: {
- breathing_sync: { cadence: breathRate.toFixed(1), sync_quality: actualMotion > 0.5 ? 0.7 : 0.85 },
- gesture: { type: exerciseType !== 'rest' && exerciseType !== 'cooldown' ? 'exercise_rep' : 'none', count: repCount },
- vital_trend: {
- status: hrRate > 170 ? 'warning' : (hrRate > 140 ? 'elevated' : 'normal'),
- hr_zone: hrRate > 155 ? 'anaerobic' : (hrRate > 130 ? 'threshold' : (hrRate > 110 ? 'aerobic' : (hrRate > 90 ? 'warmup' : 'resting'))),
- hr_lag_s: exerciseType === 'rest' ? 'recovering' : 'tracking',
- intensity: (targetIntensity * 100).toFixed(0) + '%',
- workout_phase: exerciseType,
- },
- },
- });
- }
-
- // ========================================================================
- // 12. Security Patrol — checkpoint pauses, speed variation, anomaly buildup
- // ========================================================================
-
- _securityPatrol(t) {
- const n = _getNoiseBank('security_patrol');
-
- // Patrol route: rectangular with checkpoint pauses at corners
- const patrolSpeed = 0.18; // slightly slower for realism
- const rawPatrolT = (t * patrolSpeed) % 1; // 0..1 around route
-
- // Checkpoint pauses: guard slows/pauses at each corner (0.25, 0.5, 0.75, 1.0)
- // Remap rawPatrolT to account for pauses
- const cornerDuration = 0.04; // proportion of circuit spent pausing at each corner
- let patrolT = rawPatrolT;
- let atCheckpoint = false;
- let checkpointCorner = -1;
- const corners = [0, 0.25, 0.5, 0.75];
- for (let ci = 0; ci < 4; ci++) {
- const c = corners[ci];
- const next = ci < 3 ? corners[ci + 1] : 1;
- if (rawPatrolT >= c && rawPatrolT < c + cornerDuration) {
- patrolT = c;
- atCheckpoint = true;
- checkpointCorner = ci;
- break;
- }
- }
-
- // Speed variation: faster on long stretches, slower near corners
- let px, pz, facing;
- if (patrolT < 0.25) {
- const p = patrolT / 0.25;
- px = -3 + p * 6; pz = -2; facing = 0;
- } else if (patrolT < 0.5) {
- const p = (patrolT - 0.25) / 0.25;
- px = 3; pz = -2 + p * 4; facing = Math.PI * 0.5;
- } else if (patrolT < 0.75) {
- const p = (patrolT - 0.5) / 0.25;
- px = 3 - p * 6; pz = 2; facing = Math.PI;
- } else {
- const p = (patrolT - 0.75) / 0.25;
- px = -3; pz = 2 - p * 4; facing = Math.PI * 1.5;
- }
-
- // At checkpoint: guard looks around (facing oscillates)
- if (atCheckpoint) {
- facing += Math.sin(t * 3) * 0.8; // scanning left-right
- }
-
- // Add natural movement noise
- px += n.pos1(t) * 0.05;
- pz += n.pos2(t) * 0.05;
-
- const guardSpeed = atCheckpoint ? 5 : (80 + _harmonicNoise(t, 10.1, 2) * 20);
- const zone = px > 0 ? (pz > 0 ? 'NE' : 'SE') : (pz > 0 ? 'NW' : 'SW');
-
- // Anomaly: starts as faint signal, builds confidence, guard responds
- const anomalyCycle = t % 25;
- const anomalyFaint = anomalyCycle >= 14 && anomalyCycle < 17; // first hints
- const anomalyBuilding = anomalyCycle >= 17 && anomalyCycle < 19; // confidence builds
- const anomalyConfirmed = anomalyCycle >= 19 && anomalyCycle < 22; // confirmed, guard responds
- const anomalyActive = anomalyFaint || anomalyBuilding || anomalyConfirmed;
-
- let anomalyScore = 0;
- if (anomalyFaint) anomalyScore = 0.15 + _smoothstep(14, 17, anomalyCycle) * 0.2;
- else if (anomalyBuilding) anomalyScore = 0.35 + _smoothstep(17, 19, anomalyCycle) * 0.35;
- else if (anomalyConfirmed) anomalyScore = 0.7 + _smoothstep(19, 20, anomalyCycle) * 0.15;
-
- // Anomaly position (opposite quadrant)
- const ax = -px * 0.5 + Math.sin(t * 0.3) * 0.3;
- const az = -pz * 0.4 + Math.cos(t * 0.25) * 0.2;
-
- // Guard changes path toward anomaly when confirmed
- if (anomalyConfirmed) {
- const redirectStrength = _smoothstep(19, 20, anomalyCycle);
- px = _lerp(px, ax, redirectStrength * 0.4);
- pz = _lerp(pz, az, redirectStrength * 0.4);
- facing = Math.atan2(ax - px, az - pz);
- }
-
- const amplitude = new Float32Array(64);
- for (let i = 0; i < 64; i++) {
- amplitude[i] = 0.3 + 0.15 * Math.sin(t * 1.0 + i * 0.15)
- + _harmonicNoise(t, i * 0.13, 2) * 0.01;
- }
-
- const persons = [{
- id: 'guard',
- position: [px, 0, pz],
- motion_score: guardSpeed,
- pose: atCheckpoint ? 'standing' : (anomalyConfirmed ? 'alert' : 'walking'),
- facing,
- }];
- if (anomalyActive) {
- persons.push({
- id: 'anomaly',
- position: [ax, 0, az],
- motion_score: anomalyFaint ? 8 : (anomalyBuilding ? 15 : 25),
- pose: 'crouching',
- facing: Math.atan2(px - ax, pz - az),
- signal_confidence: anomalyScore,
- });
- }
-
- return this._baseFrame({
- nodes: [
- { node_id: 1, rssi_dbm: -40 + Math.sin(t * 0.8) * 3 + n.rssi(t) * 0.5, position: [4, 2, -4], amplitude, subcarrier_count: 64 },
- { node_id: 2, rssi_dbm: -42 + Math.sin(t * 0.6) * 2 + n.env(t) * 0.3, position: [-4, 2, 4], amplitude: new Float32Array(amplitude), subcarrier_count: 64 },
- ],
- features: {
- mean_rssi: -40 + Math.sin(t * 0.8) * 3 + n.rssi(t) * 0.5,
- variance: 2.5 + Math.sin(t * 0.5) * 0.8 + (anomalyActive ? anomalyScore * 2 : 0),
- std: 1.58 + anomalyScore * 0.5,
- motion_band_power: atCheckpoint ? 0.03 : 0.18,
- breathing_band_power: 0.06,
- dominant_freq_hz: atCheckpoint ? 0.1 : 0.8,
- spectral_power: 0.3 + anomalyScore * 0.2,
- },
- classification: {
- motion_level: atCheckpoint ? 'present_still' : 'active',
- presence: true,
- confidence: 0.85,
- anomaly_zone: anomalyActive,
- anomaly_confidence: anomalyScore,
- },
- signal_field: {
- grid_size: [20, 1, 20],
- values: anomalyActive
- ? this._twoPresenceField(10 + px * 1.5, 10 + pz * 1.5, 10 + ax * 1.5, 10 + az * 1.5, t)
- : this._presenceField(10 + px * 1.5, 10 + pz * 1.5, 2.5, t),
- },
- vital_signs: {
- breathing_rate_bpm: 16 + n.breath(t) * 0.5,
- heart_rate_bpm: 78 + (anomalyConfirmed ? 12 : 0) + n.hr(t) * 1,
- breathing_confidence: 0.6,
- heart_rate_confidence: 0.5,
- },
- persons,
- estimated_persons: anomalyActive ? 2 : 1,
- edge_modules: {
- behavioral_profiler: {
- guard_zone: zone,
- coverage_pct: 72 + _harmonicNoise(t, 11.1, 2) * 3,
- anomaly_score: anomalyScore,
- checkpoint_active: atCheckpoint,
- checkpoint_corner: checkpointCorner >= 0 ? ['SW', 'SE', 'NE', 'NW'][checkpointCorner] : null,
- guard_response: anomalyConfirmed ? 'investigating' : (anomalyBuilding ? 'alerted' : 'patrolling'),
- },
- perimeter_breach: {
- detected: anomalyScore > 0.5,
- confidence: anomalyScore > 0.5 ? anomalyScore : 0,
- zone: anomalyActive ? (zone === 'NE' ? 'SW' : 'NE') : 'none',
- first_detected_s: anomalyFaint ? (anomalyCycle - 14).toFixed(1) : null,
- buildup_phase: anomalyFaint ? 'faint' : (anomalyBuilding ? 'building' : (anomalyConfirmed ? 'confirmed' : 'none')),
- },
- },
- });
- }
-
- // ---- Helpers ----
-
- _flatField(base) {
- const vals = [];
- // Spatially coherent noise: smooth gradient + gentle ripple
- for (let iz = 0; iz < 20; iz++) {
- for (let ix = 0; ix < 20; ix++) {
- const gradient = Math.sin(ix * 0.3) * Math.sin(iz * 0.25) * 0.01;
- const ripple = _harmonicNoise(ix * 0.5 + iz * 0.7, ix + iz * 20, 2) * 0.005;
- vals.push(_clamp(base + gradient + ripple, 0, 1));
- }
- }
- return vals;
- }
-
- _presenceField(cx, cz, radius, t) {
- const vals = [];
- for (let iz = 0; iz < 20; iz++) {
- for (let ix = 0; ix < 20; ix++) {
- const dx = ix - cx, dz = iz - cz;
- const d = Math.sqrt(dx * dx + dz * dz);
- // Spatially coherent noise (smooth, not random per cell)
- const noise = _harmonicNoise(t * 0.5 + ix * 0.4 + iz * 0.3, ix + iz * 20, 2) * 0.015;
- const v = _gaussian(d, radius) * 0.7 + noise;
- vals.push(_clamp(v, 0, 1));
- }
- }
- return vals;
- }
-
- _twoPresenceField(x1, z1, x2, z2, t) {
- const vals = [];
- for (let iz = 0; iz < 20; iz++) {
- for (let ix = 0; ix < 20; ix++) {
- const d1 = Math.sqrt((ix - x1) ** 2 + (iz - z1) ** 2);
- const d2 = Math.sqrt((ix - x2) ** 2 + (iz - z2) ** 2);
- const v1 = _gaussian(d1, 1.7) * 0.6;
- const v2 = _gaussian(d2, 1.7) * 0.55;
- const noise = _harmonicNoise(t * 0.5 + ix * 0.4 + iz * 0.3, ix + iz * 20, 2) * 0.012;
- vals.push(_clamp(v1 + v2 + noise, 0, 1));
- }
- }
- return vals;
- }
-
- /** Search & rescue field with scanning sweep and gradual target lock */
- _searchRescueField(t, scanning, detected, confidence) {
- const vals = [];
- const targetCx = 14, targetCz = 10;
- for (let iz = 0; iz < 20; iz++) {
- for (let ix = 0; ix < 20; ix++) {
- let v = 0;
- // Scan sweep (rotating beam)
- if (scanning) {
- const scanAngle = t * 0.8;
- const cellAngle = Math.atan2(iz - 10, ix - 10);
- const angleDiff = Math.abs(((cellAngle - scanAngle + Math.PI) % (2 * Math.PI)) - Math.PI);
- v += _gaussian(angleDiff, 0.5) * 0.15;
- }
- // Target presence (gradually intensifying)
- if (detected) {
- const d = Math.sqrt((ix - targetCx) ** 2 + (iz - targetCz) ** 2);
- v += _gaussian(d, 3.5 - confidence * 2) * confidence * 0.7;
- }
- // Background noise
- v += _harmonicNoise(t * 0.3 + ix * 0.5 + iz * 0.6, ix + iz * 20, 2) * 0.01;
- vals.push(_clamp(v, 0, 1));
- }
- }
- return vals;
- }
-
- _generateIQ(count, scale, t) {
- const iq = [];
- for (let i = 0; i < count; i++) {
- const phase = t * 0.5 + i * 0.2 + Math.sin(t * 0.3 + i * 0.1) * 0.5;
- const amp = scale * (0.5 + 0.5 * Math.sin(t * 0.2 + i * 0.15));
- iq.push({ i: amp * Math.cos(phase), q: amp * Math.sin(phase) });
- }
- return iq;
- }
-
- _generateVariance(count, scale, t) {
- const v = new Float32Array(count);
- for (let i = 0; i < count; i++) v[i] = scale * (0.3 + 0.7 * Math.abs(Math.sin(t * 0.4 + i * 0.25)));
- return v;
- }
-
- _blend(a, b, alpha) {
- const beta = 1 - alpha;
- const result = JSON.parse(JSON.stringify(b));
-
- if (a.features && b.features) {
- for (const key of Object.keys(b.features)) {
- if (typeof b.features[key] === 'number' && typeof a.features[key] === 'number')
- result.features[key] = a.features[key] * beta + b.features[key] * alpha;
- }
- }
- if (a.signal_field?.values && b.signal_field?.values) {
- const len = Math.min(a.signal_field.values.length, b.signal_field.values.length);
- for (let i = 0; i < len; i++) result.signal_field.values[i] = a.signal_field.values[i] * beta + b.signal_field.values[i] * alpha;
- }
- if (a.vital_signs && b.vital_signs) {
- for (const key of Object.keys(b.vital_signs)) {
- if (typeof b.vital_signs[key] === 'number' && typeof a.vital_signs[key] === 'number')
- result.vital_signs[key] = a.vital_signs[key] * beta + b.vital_signs[key] * alpha;
- }
- }
- if (a.nodes?.[0]?.amplitude && b.nodes?.[0]?.amplitude) {
- const ampA = a.nodes[0].amplitude, ampB = b.nodes[0].amplitude;
- const len = Math.min(ampA.length, ampB.length);
- const blended = new Float32Array(len);
- for (let i = 0; i < len; i++) blended[i] = ampA[i] * beta + ampB[i] * alpha;
- result.nodes[0].amplitude = blended;
- }
- return result;
- }
-}
diff --git a/observatory/js/js/holographic-panel.js b/observatory/js/js/holographic-panel.js
deleted file mode 100644
index 97c586c0..00000000
--- a/observatory/js/js/holographic-panel.js
+++ /dev/null
@@ -1,121 +0,0 @@
-/**
- * Holographic Panel — Reusable frame with border shader, scan line, title
- */
-import * as THREE from 'three';
-
-const BORDER_VERTEX = `
-varying vec2 vUv;
-void main() {
- vUv = uv;
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
-}
-`;
-
-const BORDER_FRAGMENT = `
-uniform float uTime;
-uniform vec3 uColor;
-varying vec2 vUv;
-
-void main() {
- // Thin border
- float bx = step(vUv.x, 0.015) + step(1.0 - 0.015, vUv.x);
- float by = step(vUv.y, 0.02) + step(1.0 - 0.02, vUv.y);
- float border = clamp(bx + by, 0.0, 1.0);
-
- // Scan line moving upward
- float scan = smoothstep(0.0, 0.02, abs(vUv.y - fract(uTime * 0.15))) ;
- scan = 1.0 - (1.0 - scan) * 0.4;
-
- // Corner accents
- float corner = 0.0;
- float cx = min(vUv.x, 1.0 - vUv.x);
- float cy = min(vUv.y, 1.0 - vUv.y);
- if (cx < 0.06 && cy < 0.08) corner = 0.6;
-
- // Subtle fill
- float fill = 0.03 + corner * 0.05;
-
- float alpha = max(border * 0.7, fill) * scan;
- gl_FragColor = vec4(uColor, alpha);
-}
-`;
-
-export class HolographicPanel {
- /**
- * @param {Object} opts
- * @param {number[]} opts.position - [x, y, z]
- * @param {number} opts.width
- * @param {number} opts.height
- * @param {string} opts.title
- * @param {number} [opts.color=0x00d4ff]
- */
- constructor(opts) {
- this.group = new THREE.Group();
- this.group.position.set(...opts.position);
-
- const color = new THREE.Color(opts.color || 0x00d4ff);
-
- // Border plane
- this._uniforms = {
- uTime: { value: 0 },
- uColor: { value: color },
- };
-
- const borderGeo = new THREE.PlaneGeometry(opts.width, opts.height);
- const borderMat = new THREE.ShaderMaterial({
- vertexShader: BORDER_VERTEX,
- fragmentShader: BORDER_FRAGMENT,
- uniforms: this._uniforms,
- transparent: true,
- side: THREE.DoubleSide,
- depthWrite: false,
- blending: THREE.AdditiveBlending,
- });
- this._border = new THREE.Mesh(borderGeo, borderMat);
- this.group.add(this._border);
-
- // Title sprite
- if (opts.title) {
- const canvas = document.createElement('canvas');
- canvas.width = 512;
- canvas.height = 64;
- const ctx = canvas.getContext('2d');
- ctx.fillStyle = 'transparent';
- ctx.fillRect(0, 0, 512, 64);
- ctx.font = '600 28px "Courier New", monospace';
- ctx.fillStyle = `#${color.getHexString()}`;
- ctx.textAlign = 'center';
- ctx.fillText(opts.title.toUpperCase(), 256, 42);
-
- const tex = new THREE.CanvasTexture(canvas);
- const spriteMat = new THREE.SpriteMaterial({
- map: tex,
- transparent: true,
- blending: THREE.AdditiveBlending,
- depthWrite: false,
- });
- const sprite = new THREE.Sprite(spriteMat);
- sprite.scale.set(opts.width * 0.8, opts.width * 0.1, 1);
- sprite.position.y = opts.height / 2 + 0.3;
- this.group.add(sprite);
- this._titleSprite = sprite;
- this._titleTex = tex;
- }
- }
-
- update(dt, elapsed) {
- this._uniforms.uTime.value = elapsed;
- }
-
- /** Make panel face camera */
- lookAt(cameraPos) {
- this.group.lookAt(cameraPos);
- }
-
- dispose() {
- this._border.geometry.dispose();
- this._border.material.dispose();
- if (this._titleTex) this._titleTex.dispose();
- if (this._titleSprite) this._titleSprite.material.dispose();
- }
-}
diff --git a/observatory/js/js/hud-controller.js b/observatory/js/js/hud-controller.js
deleted file mode 100644
index 14afc6b5..00000000
--- a/observatory/js/js/hud-controller.js
+++ /dev/null
@@ -1,567 +0,0 @@
-/**
- * HudController — Extracted HUD update, settings dialog, and scenario UI
- *
- * Manages all DOM-based HUD elements:
- * - Vital sign display with smooth lerp transitions and color coding
- * - Signal metrics, sparkline, and presence indicator
- * - Scenario description and edge module badges
- * - Mini person-count dot visualization
- * - Settings dialog (tabs, ranges, presets, data source)
- * - Quick-select scenario dropdown
- */
-
-// ---- Constants ----
-
-export const SCENARIO_NAMES = [
- 'EMPTY ROOM','VITAL SIGNS','MULTI-PERSON','FALL DETECT',
- 'SLEEP MONITOR','INTRUSION','GESTURE CTRL','CROWD OCCUPANCY',
- 'SEARCH RESCUE','ELDERLY CARE','FITNESS','SECURITY PATROL',
-];
-
-export const DEFAULTS = {
- bloom: 0.08, bloomRadius: 0.2, bloomThresh: 0.6,
- exposure: 1.3, vignette: 0.25, grain: 0.01, chromatic: 0.0005,
- boneThick: 0.018, jointSize: 0.035, glow: 0.3, trail: 0.35,
- wireColor: '#00d878', jointColor: '#ff4060', aura: 0.02,
- field: 0.45, waves: 0.4, ambient: 0.7, reflect: 0.2,
- fov: 50, orbitSpeed: 0.15, grid: true, room: true,
- scenario: 'auto', cycle: 30, dataSource: 'demo', wsUrl: '',
-};
-
-export const SETTINGS_VERSION = '6';
-
-export const PRESETS = {
- foundation: {},
- cinematic: {
- bloom: 1.2, bloomRadius: 0.5, bloomThresh: 0.2,
- exposure: 0.8, vignette: 0.7, grain: 0.04, chromatic: 0.002,
- glow: 0.6, trail: 0.8, aura: 0.06, field: 0.4,
- waves: 0.7, ambient: 0.25, reflect: 0.5, fov: 40, orbitSpeed: 0.08,
- },
- minimal: {
- bloom: 0.3, bloomRadius: 0.2, bloomThresh: 0.5,
- exposure: 1.1, vignette: 0.2, grain: 0, chromatic: 0,
- glow: 0.3, trail: 0.2, aura: 0.02, field: 0.7,
- waves: 0.3, ambient: 0.6, reflect: 0.1, wireColor: '#40ff90', jointColor: '#4080ff',
- },
- neon: {
- bloom: 2.5, bloomRadius: 0.8, bloomThresh: 0.1,
- exposure: 0.6, vignette: 0.6, grain: 0.02, chromatic: 0.004,
- glow: 2.0, trail: 1.0, aura: 0.15, field: 0.6,
- waves: 1.0, ambient: 0.15, reflect: 0.7, wireColor: '#00ffaa', jointColor: '#ff00ff',
- },
- tactical: {
- bloom: 0.5, bloomRadius: 0.3, bloomThresh: 0.4,
- exposure: 0.85, vignette: 0.4, grain: 0.04, chromatic: 0.001,
- glow: 0.5, trail: 0.4, aura: 0.03, field: 0.8,
- waves: 0.4, ambient: 0.3, reflect: 0.15, wireColor: '#30ff60', jointColor: '#ff8800',
- },
- medical: {
- bloom: 0.6, bloomRadius: 0.4, bloomThresh: 0.35,
- exposure: 1.0, vignette: 0.3, grain: 0.01, chromatic: 0.0005,
- glow: 0.6, trail: 0.3, aura: 0.04, field: 0.5,
- waves: 0.3, ambient: 0.5, reflect: 0.2, wireColor: '#00ccff', jointColor: '#ff3355',
- },
-};
-
-// Scenario descriptions shown below the dropdown
-const SCENARIO_DESCRIPTIONS = {
- auto: 'Auto-cycling through all sensing scenarios.',
- empty_room: 'Baseline calibration with no human presence in the monitored zone.',
- single_breathing: 'Detecting vital signs through WiFi signal micro-variations.',
- two_walking: 'Tracking multiple people simultaneously via CSI multiplex separation.',
- fall_event: 'Sudden posture-change detection using acceleration feature analysis.',
- sleep_monitoring: 'Monitoring breathing patterns and apnea events during sleep.',
- intrusion_detect: 'Passive perimeter monitoring -- no cameras, pure RF sensing.',
- gesture_control: 'DTW-based gesture recognition from hand/arm motion signatures.',
- crowd_occupancy: 'Estimating room occupancy count from aggregate CSI variance.',
- search_rescue: 'Through-wall survivor detection using WiFi-MAT multistatic mode.',
- elderly_care: 'Continuous gait analysis for early mobility-decline detection.',
- fitness_tracking: 'Rep counting and exercise classification from body kinematics.',
- security_patrol: 'Multi-zone presence patrol with camera-free motion heatmaps.',
-};
-
-// Edge modules active per scenario
-const SCENARIO_EDGE_MODULES = {
- auto: [],
- empty_room: [],
- single_breathing: ['VITALS'],
- two_walking: ['GAIT', 'TRACKING'],
- fall_event: ['FALL', 'VITALS'],
- sleep_monitoring: ['VITALS', 'APNEA'],
- intrusion_detect: ['PRESENCE', 'ALERT'],
- gesture_control: ['GESTURE', 'DTW'],
- crowd_occupancy: ['OCCUPANCY'],
- search_rescue: ['MAT', 'VITALS', 'PRESENCE'],
- elderly_care: ['GAIT', 'VITALS', 'FALL'],
- fitness_tracking: ['GESTURE', 'GAIT'],
- security_patrol: ['PRESENCE', 'ALERT', 'TRACKING'],
-};
-
-// Edge-module badge colors
-const MODULE_COLORS = {
- VITALS: 'var(--red-heart)',
- GAIT: 'var(--green-glow)',
- FALL: 'var(--red-alert)',
- GESTURE: 'var(--amber)',
- PRESENCE: 'var(--blue-signal)',
- TRACKING: 'var(--green-bright)',
- OCCUPANCY: 'var(--amber)',
- ALERT: 'var(--red-alert)',
- DTW: 'var(--amber)',
- APNEA: 'var(--red-heart)',
- MAT: 'var(--blue-signal)',
-};
-
-// Vital-sign color-coding thresholds
-function vitalColor(type, value) {
- if (value <= 0) return 'var(--text-secondary)';
- if (type === 'hr') {
- if (value < 50 || value > 130) return 'var(--red-alert)';
- if (value < 60 || value > 100) return 'var(--amber)';
- return 'var(--green-glow)';
- }
- if (type === 'br') {
- if (value < 8 || value > 28) return 'var(--red-alert)';
- if (value < 12 || value > 20) return 'var(--amber)';
- return 'var(--green-glow)';
- }
- if (type === 'conf') {
- if (value < 40) return 'var(--red-alert)';
- if (value < 70) return 'var(--amber)';
- return 'var(--green-glow)';
- }
- return 'var(--text-primary)';
-}
-
-function lerp(a, b, t) {
- return a + (b - a) * t;
-}
-
-// ---- HudController class ----
-
-export class HudController {
- constructor(observatory) {
- this._obs = observatory;
- this._settingsOpen = false;
- this._rssiHistory = [];
- this._sparklineCtx = document.getElementById('rssi-sparkline')?.getContext('2d');
-
- // Lerp state for smooth vital-sign transitions
- this._lerpHr = 0;
- this._lerpBr = 0;
- this._lerpConf = 0;
-
- // Track current scenario for description/edge updates
- this._currentScenarioKey = null;
- }
-
- // ============================================================
- // Settings dialog
- // ============================================================
-
- initSettings() {
- const overlay = document.getElementById('settings-overlay');
- const btn = document.getElementById('settings-btn');
- const closeBtn = document.getElementById('settings-close');
- btn.addEventListener('click', () => this.toggleSettings());
- closeBtn.addEventListener('click', () => this.toggleSettings());
- overlay.addEventListener('click', (e) => { if (e.target === overlay) this.toggleSettings(); });
-
- // Tab switching
- document.querySelectorAll('.stab').forEach(tab => {
- tab.addEventListener('click', () => {
- document.querySelectorAll('.stab').forEach(t => t.classList.remove('active'));
- document.querySelectorAll('.stab-content').forEach(c => c.classList.remove('active'));
- tab.classList.add('active');
- document.getElementById(`stab-${tab.dataset.stab}`).classList.add('active');
- });
- });
-
- const obs = this._obs;
- const s = obs.settings;
-
- // Bind ranges
- this._bindRange('opt-bloom', 'bloom', v => { obs._postProcessing._bloomPass.strength = v; });
- this._bindRange('opt-bloom-radius', 'bloomRadius', v => { obs._postProcessing._bloomPass.radius = v; });
- this._bindRange('opt-bloom-thresh', 'bloomThresh', v => { obs._postProcessing._bloomPass.threshold = v; });
- this._bindRange('opt-exposure', 'exposure', v => { obs._renderer.toneMappingExposure = v; });
- this._bindRange('opt-vignette', 'vignette', v => { obs._postProcessing._vignettePass.uniforms.uVignetteStrength.value = v; });
- this._bindRange('opt-grain', 'grain', v => { obs._postProcessing._vignettePass.uniforms.uGrainStrength.value = v; });
- this._bindRange('opt-chromatic', 'chromatic', v => { obs._postProcessing._vignettePass.uniforms.uChromaticStrength.value = v; });
- this._bindRange('opt-bone-thick', 'boneThick');
- this._bindRange('opt-joint-size', 'jointSize');
- this._bindRange('opt-glow', 'glow');
- this._bindRange('opt-trail', 'trail');
- this._bindRange('opt-aura', 'aura');
- this._bindRange('opt-field', 'field', v => { obs._fieldMat.opacity = v; });
- this._bindRange('opt-waves', 'waves');
- this._bindRange('opt-ambient', 'ambient', v => { obs._ambient.intensity = v * 5.0; });
- this._bindRange('opt-reflect', 'reflect', v => {
- obs._floorMat.roughness = 1.0 - v * 0.7;
- obs._floorMat.metalness = v * 0.5;
- });
- this._bindRange('opt-fov', 'fov', v => {
- obs._camera.fov = v;
- obs._camera.updateProjectionMatrix();
- });
- this._bindRange('opt-orbit-speed', 'orbitSpeed');
- this._bindRange('opt-cycle', 'cycle', v => { obs._demoData.setCycleDuration(v); });
-
- // Color pickers
- document.getElementById('opt-wire-color').value = s.wireColor;
- document.getElementById('opt-wire-color').addEventListener('input', (e) => {
- s.wireColor = e.target.value; obs._applyColors(); this.saveSettings();
- });
- document.getElementById('opt-joint-color').value = s.jointColor;
- document.getElementById('opt-joint-color').addEventListener('input', (e) => {
- s.jointColor = e.target.value; obs._applyColors(); this.saveSettings();
- });
-
- // Checkboxes
- document.getElementById('opt-grid').checked = s.grid;
- document.getElementById('opt-grid').addEventListener('change', (e) => {
- s.grid = e.target.checked; obs._grid.visible = e.target.checked; this.saveSettings();
- });
- document.getElementById('opt-room').checked = s.room;
- document.getElementById('opt-room').addEventListener('change', (e) => {
- s.room = e.target.checked; obs._roomWire.visible = e.target.checked; this.saveSettings();
- });
-
- // Scenario select
- const scenarioSel = document.getElementById('opt-scenario');
- scenarioSel.value = s.scenario;
- scenarioSel.addEventListener('change', (e) => {
- s.scenario = e.target.value;
- obs._demoData.setScenario(e.target.value);
- this.saveSettings();
- });
-
- // Data source
- const dsSel = document.getElementById('opt-data-source');
- dsSel.value = s.dataSource;
- dsSel.addEventListener('change', (e) => {
- s.dataSource = e.target.value;
- document.getElementById('ws-url-row').style.display = e.target.value === 'ws' ? 'flex' : 'none';
- if (e.target.value === 'ws' && s.wsUrl) obs._connectWS(s.wsUrl);
- else obs._disconnectWS();
- this.updateSourceBadge(s.dataSource, obs._ws);
- this.saveSettings();
- });
- document.getElementById('ws-url-row').style.display = s.dataSource === 'ws' ? 'flex' : 'none';
-
- const wsInput = document.getElementById('opt-ws-url');
- wsInput.value = s.wsUrl;
- wsInput.addEventListener('change', (e) => {
- s.wsUrl = e.target.value;
- if (s.dataSource === 'ws') obs._connectWS(e.target.value);
- this.saveSettings();
- });
-
- // Buttons
- document.getElementById('btn-reset-camera').addEventListener('click', () => {
- obs._camera.position.set(6, 5, 8);
- obs._controls.target.set(0, 1.2, 0);
- obs._controls.update();
- });
- document.getElementById('btn-export-settings').addEventListener('click', () => {
- const blob = new Blob([JSON.stringify(s, null, 2)], { type: 'application/json' });
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = 'ruview-observatory-settings.json';
- a.click();
- });
- document.getElementById('btn-reset-settings').addEventListener('click', () => {
- this.applyPreset(DEFAULTS);
- });
-
- const presetSel = document.getElementById('opt-preset');
- presetSel.addEventListener('change', (e) => {
- const p = PRESETS[e.target.value];
- if (p) this.applyPreset({ ...DEFAULTS, ...p });
- });
-
- obs._grid.visible = s.grid;
- obs._roomWire.visible = s.room;
- }
-
- // ============================================================
- // Quick-select (top bar scenario dropdown)
- // ============================================================
-
- initQuickSelect() {
- const sel = document.getElementById('scenario-quick-select');
- if (!sel) return;
- sel.addEventListener('change', (e) => {
- this._obs._demoData.setScenario(e.target.value);
- const settingsSel = document.getElementById('opt-scenario');
- if (settingsSel) settingsSel.value = e.target.value;
- this._obs.settings.scenario = e.target.value;
- this.saveSettings();
- });
- }
-
- // ============================================================
- // Toggle / save / preset
- // ============================================================
-
- toggleSettings() {
- this._settingsOpen = !this._settingsOpen;
- document.getElementById('settings-overlay').style.display = this._settingsOpen ? 'flex' : 'none';
- }
-
- get settingsOpen() {
- return this._settingsOpen;
- }
-
- saveSettings() {
- try {
- localStorage.setItem('ruview-observatory-settings', JSON.stringify(this._obs.settings));
- } catch {}
- }
-
- applyPreset(preset) {
- const obs = this._obs;
- Object.assign(obs.settings, preset);
- this.saveSettings();
- const rangeMap = {
- 'opt-bloom': 'bloom', 'opt-bloom-radius': 'bloomRadius', 'opt-bloom-thresh': 'bloomThresh',
- 'opt-exposure': 'exposure', 'opt-vignette': 'vignette', 'opt-grain': 'grain', 'opt-chromatic': 'chromatic',
- 'opt-bone-thick': 'boneThick', 'opt-joint-size': 'jointSize', 'opt-glow': 'glow', 'opt-trail': 'trail', 'opt-aura': 'aura',
- 'opt-field': 'field', 'opt-waves': 'waves', 'opt-ambient': 'ambient', 'opt-reflect': 'reflect',
- 'opt-fov': 'fov', 'opt-orbit-speed': 'orbitSpeed', 'opt-cycle': 'cycle',
- };
- for (const [id, key] of Object.entries(rangeMap)) {
- const el = document.getElementById(id);
- const valEl = document.getElementById(`${id}-val`);
- if (el) el.value = obs.settings[key];
- if (valEl) valEl.textContent = obs.settings[key];
- }
- const gridEl = document.getElementById('opt-grid');
- if (gridEl) { gridEl.checked = obs.settings.grid; obs._grid.visible = obs.settings.grid; }
- const roomEl = document.getElementById('opt-room');
- if (roomEl) { roomEl.checked = obs.settings.room; obs._roomWire.visible = obs.settings.room; }
- document.getElementById('opt-wire-color').value = obs.settings.wireColor;
- document.getElementById('opt-joint-color').value = obs.settings.jointColor;
- obs._applyPostSettings();
- obs._renderer.toneMappingExposure = obs.settings.exposure;
- obs._fieldMat.opacity = obs.settings.field;
- obs._ambient.intensity = obs.settings.ambient * 5.0;
- obs._floorMat.roughness = 1.0 - obs.settings.reflect * 0.7;
- obs._floorMat.metalness = obs.settings.reflect * 0.5;
- obs._camera.fov = obs.settings.fov;
- obs._camera.updateProjectionMatrix();
- obs._demoData.setCycleDuration(obs.settings.cycle);
- obs._applyColors();
- }
-
- // ============================================================
- // Source badge
- // ============================================================
-
- updateSourceBadge(dataSource, ws) {
- const dot = document.querySelector('#data-source-badge .dot');
- const label = document.getElementById('data-source-label');
- if (dataSource === 'ws' && ws?.readyState === WebSocket.OPEN) {
- dot.className = 'dot dot--live'; label.textContent = 'LIVE';
- } else {
- dot.className = 'dot dot--demo'; label.textContent = 'DEMO';
- }
- }
-
- // ============================================================
- // HUD update (called every frame)
- // ============================================================
-
- updateHUD(data, demoData) {
- if (!data) return;
- const vs = data.vital_signs || {};
- const feat = data.features || {};
- const cls = data.classification || {};
-
- // Sync scenario dropdown
- const quickSel = document.getElementById('scenario-quick-select');
- const cur = demoData._autoMode ? 'auto' : demoData.currentScenario;
- if (quickSel && quickSel.value !== cur) quickSel.value = cur;
- const autoIcon = document.getElementById('autoplay-icon');
- if (autoIcon) autoIcon.className = demoData._autoMode ? '' : 'hidden';
-
- const targetHr = vs.heart_rate_bpm || 0;
- const targetBr = vs.breathing_rate_bpm || 0;
- const targetConf = Math.round((cls.confidence || 0) * 100);
-
- // Smooth lerp transitions (blend 4% per frame toward target — very stable)
- const lerpFactor = 0.04;
- this._lerpHr = targetHr > 0 ? lerp(this._lerpHr, targetHr, lerpFactor) : 0;
- this._lerpBr = targetBr > 0 ? lerp(this._lerpBr, targetBr, lerpFactor) : 0;
- this._lerpConf = targetConf > 0 ? lerp(this._lerpConf, targetConf, lerpFactor) : 0;
-
- const dispHr = this._lerpHr > 1 ? Math.round(this._lerpHr) : '--';
- const dispBr = this._lerpBr > 1 ? Math.round(this._lerpBr) : '--';
- const dispConf = this._lerpConf > 1 ? Math.round(this._lerpConf) : '--';
-
- this._setText('hr-value', dispHr);
- this._setText('br-value', dispBr);
- this._setText('conf-value', dispConf);
- this._setWidth('hr-bar', Math.min(100, this._lerpHr / 120 * 100));
- this._setWidth('br-bar', Math.min(100, this._lerpBr / 30 * 100));
- this._setWidth('conf-bar', this._lerpConf);
-
- // Color-code vital values
- this._setColor('hr-value', vitalColor('hr', this._lerpHr));
- this._setColor('br-value', vitalColor('br', this._lerpBr));
- this._setColor('conf-value', vitalColor('conf', this._lerpConf));
-
- // Color-code bar fills to match
- this._setBarColor('hr-bar', vitalColor('hr', this._lerpHr));
- this._setBarColor('br-bar', vitalColor('br', this._lerpBr));
- this._setBarColor('conf-bar', vitalColor('conf', this._lerpConf));
-
- this._setText('rssi-value', `${Math.round(feat.mean_rssi || 0)} dBm`);
- this._setText('var-value', (feat.variance || 0).toFixed(2));
- this._setText('motion-value', (feat.motion_band_power || 0).toFixed(3));
-
- // Mini person-count dots
- const personCount = data.estimated_persons || 0;
- this._updatePersonDots(personCount);
-
- const presEl = document.getElementById('presence-indicator');
- const presLabel = document.getElementById('presence-label');
- if (presEl) {
- const ml = cls.motion_level || 'absent';
- presEl.className = 'presence-state';
- if (ml === 'active') { presEl.classList.add('presence--active'); presLabel.textContent = 'ACTIVE'; }
- else if (cls.presence) { presEl.classList.add('presence--present'); presLabel.textContent = 'PRESENT'; }
- else { presEl.classList.add('presence--absent'); presLabel.textContent = 'ABSENT'; }
- }
-
- const fallEl = document.getElementById('fall-alert');
- if (fallEl) fallEl.style.display = cls.fall_detected ? 'block' : 'none';
-
- // Scenario description and edge modules
- const scenarioKey = demoData._autoMode ? (demoData.currentScenario || 'auto') : (demoData.currentScenario || 'auto');
- if (scenarioKey !== this._currentScenarioKey) {
- this._currentScenarioKey = scenarioKey;
- this._updateScenarioDescription(scenarioKey);
- this._updateEdgeModules(scenarioKey);
- }
- }
-
- // ============================================================
- // Sparkline
- // ============================================================
-
- updateSparkline(data) {
- const rssi = data?.features?.mean_rssi;
- if (rssi == null || !this._sparklineCtx) return;
- this._rssiHistory.push(rssi);
- if (this._rssiHistory.length > 60) this._rssiHistory.shift();
-
- const ctx = this._sparklineCtx;
- const w = ctx.canvas.width, h = ctx.canvas.height;
- ctx.clearRect(0, 0, w, h);
- if (this._rssiHistory.length < 2) return;
-
- ctx.beginPath();
- ctx.strokeStyle = '#2090ff';
- ctx.lineWidth = 1.5;
- ctx.shadowColor = '#2090ff';
- ctx.shadowBlur = 4;
- for (let i = 0; i < this._rssiHistory.length; i++) {
- const x = (i / (this._rssiHistory.length - 1)) * w;
- const norm = Math.max(0, Math.min(1, (this._rssiHistory[i] + 80) / 60));
- const y = h - norm * h;
- i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
- }
- ctx.stroke();
- ctx.shadowBlur = 0;
- ctx.lineTo(w, h);
- ctx.lineTo(0, h);
- ctx.closePath();
- const grad = ctx.createLinearGradient(0, 0, 0, h);
- grad.addColorStop(0, 'rgba(32,144,255,0.15)');
- grad.addColorStop(1, 'rgba(32,144,255,0)');
- ctx.fillStyle = grad;
- ctx.fill();
- }
-
- // ============================================================
- // Private helpers
- // ============================================================
-
- _setText(id, val) {
- const e = document.getElementById(id);
- if (e) e.textContent = val;
- }
-
- _setWidth(id, pct) {
- const e = document.getElementById(id);
- if (e) e.style.width = `${pct}%`;
- }
-
- _setColor(id, color) {
- const e = document.getElementById(id);
- if (e) e.style.color = color;
- }
-
- _setBarColor(id, color) {
- const e = document.getElementById(id);
- if (e) e.style.background = color;
- }
-
- _bindRange(id, key, applyFn) {
- const el = document.getElementById(id);
- const valEl = document.getElementById(`${id}-val`);
- if (!el) return;
- el.value = this._obs.settings[key];
- if (valEl) valEl.textContent = this._obs.settings[key];
- el.addEventListener('input', (e) => {
- const v = parseFloat(e.target.value);
- this._obs.settings[key] = v;
- if (valEl) valEl.textContent = v;
- if (applyFn) applyFn(v);
- this.saveSettings();
- });
- }
-
- _updatePersonDots(count) {
- const container = document.getElementById('persons-dots');
- if (!container) {
- // Fall back to text-only display
- this._setText('persons-value', count);
- return;
- }
- // Build dot icons: filled for detected persons, dim for empty slots (max 8)
- const maxDots = 8;
- const clamped = Math.min(count, maxDots);
- let html = '';
- for (let i = 0; i < maxDots; i++) {
- const active = i < clamped;
- html += ``;
- }
- container.innerHTML = html;
- this._setText('persons-value', count);
- }
-
- _updateScenarioDescription(scenarioKey) {
- const el = document.getElementById('scenario-description');
- if (!el) return;
- el.textContent = SCENARIO_DESCRIPTIONS[scenarioKey] || '';
- }
-
- _updateEdgeModules(scenarioKey) {
- const bar = document.getElementById('edge-modules-bar');
- if (!bar) return;
- const modules = SCENARIO_EDGE_MODULES[scenarioKey] || [];
- if (modules.length === 0) {
- bar.innerHTML = '';
- bar.style.display = 'none';
- return;
- }
- bar.style.display = 'flex';
- bar.innerHTML = modules.map(m => {
- const color = MODULE_COLORS[m] || 'var(--text-secondary)';
- return `${m}`;
- }).join('');
- }
-}
diff --git a/observatory/js/js/main.js b/observatory/js/js/main.js
deleted file mode 100644
index 26abbe2c..00000000
--- a/observatory/js/js/main.js
+++ /dev/null
@@ -1,715 +0,0 @@
-/**
- * RuView Observatory — Main Scene Orchestrator
- *
- * Room-based WiFi sensing visualization with:
- * - Pool of 4 human wireframe figures (multi-person scenarios)
- * - 7 pose types (standing, walking, lying, sitting, fallen, exercising, gesturing, crouching)
- * - Scenario-specific room props (chair, exercise mat, door, rubble wall, screen, desk)
- * - Dot-matrix mist body mass, particle trails, WiFi waves, signal field
- * - Reflective floor, settings dialog, and practical data HUD
- */
-import * as THREE from 'three';
-import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
-
-import { DemoDataGenerator } from './demo-data.js';
-import { NebulaBackground } from './nebula-background.js';
-import { PostProcessing } from './post-processing.js';
-import { FigurePool, SKELETON_PAIRS } from './figure-pool.js';
-import { PoseSystem } from './pose-system.js';
-import { ScenarioProps } from './scenario-props.js';
-import { HudController, DEFAULTS, SETTINGS_VERSION, PRESETS, SCENARIO_NAMES } from './hud-controller.js';
-
-// ---- Palette ----
-const C = {
- greenGlow: 0x00d878,
- greenBright:0x3eff8a,
- greenDim: 0x0a6b3a,
- amber: 0xffb020,
- blueSignal: 0x2090ff,
- redAlert: 0xff3040,
- redHeart: 0xff4060,
- bgDeep: 0x080c14,
-};
-
-// SCENARIO_NAMES, DEFAULTS, SETTINGS_VERSION, PRESETS imported from hud-controller.js
-
-// ---- Main Class ----
-
-class Observatory {
- constructor() {
- this._canvas = document.getElementById('observatory-canvas');
- this.settings = { ...DEFAULTS };
-
- // Load saved settings
- try {
- const ver = localStorage.getItem('ruview-settings-version');
- if (ver === SETTINGS_VERSION) {
- const saved = localStorage.getItem('ruview-observatory-settings');
- if (saved) Object.assign(this.settings, JSON.parse(saved));
- } else {
- localStorage.removeItem('ruview-observatory-settings');
- localStorage.setItem('ruview-settings-version', SETTINGS_VERSION);
- }
- } catch {}
-
- // Renderer
- this._renderer = new THREE.WebGLRenderer({
- canvas: this._canvas,
- antialias: true,
- powerPreference: 'high-performance',
- });
- this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
- this._renderer.setSize(window.innerWidth, window.innerHeight);
- this._renderer.toneMapping = THREE.ACESFilmicToneMapping;
- this._renderer.toneMappingExposure = this.settings.exposure;
- this._renderer.shadowMap.enabled = true;
- this._renderer.shadowMap.type = THREE.PCFSoftShadowMap;
-
- // Scene
- this._scene = new THREE.Scene();
- this._scene.background = new THREE.Color(C.bgDeep);
- this._scene.fog = new THREE.FogExp2(C.bgDeep, 0.005);
-
- // Camera
- this._camera = new THREE.PerspectiveCamera(
- this.settings.fov, window.innerWidth / window.innerHeight, 0.1, 300
- );
- this._camera.position.set(6, 5, 8);
- this._camera.lookAt(0, 1.2, 0);
-
- // Controls
- this._controls = new OrbitControls(this._camera, this._canvas);
- this._controls.enableDamping = true;
- this._controls.dampingFactor = 0.08;
- this._controls.minDistance = 2;
- this._controls.maxDistance = 25;
- this._controls.maxPolarAngle = Math.PI * 0.88;
- this._controls.target.set(0, 1.2, 0);
- this._controls.update();
-
- this._clock = new THREE.Clock();
-
- // Data
- this._demoData = new DemoDataGenerator();
- this._demoData.setCycleDuration(this.settings.cycle || 30);
- if (this.settings.scenario && this.settings.scenario !== 'auto') {
- this._demoData.setScenario(this.settings.scenario);
- }
- this._currentData = null;
- this._currentScenario = null;
-
- // Build scene
- this._setupLighting();
- this._nebula = new NebulaBackground(this._scene);
- this._buildRoom();
- this._buildRouter();
- this._poseSystem = new PoseSystem();
- this._figurePool = new FigurePool(this._scene, this.settings, this._poseSystem);
- this._scenarioProps = new ScenarioProps(this._scene);
- this._buildDotMatrixMist();
- this._buildParticleTrail();
- this._buildWifiWaves();
- this._buildSignalField();
-
- // Post-processing
- this._postProcessing = new PostProcessing(this._renderer, this._scene, this._camera);
- this._applyPostSettings();
-
- // HUD controller (settings dialog, sparkline, vital displays)
- this._hud = new HudController(this);
-
- // State
- this._autopilot = false;
- this._autoAngle = 0;
- this._fpsFrames = 0;
- this._fpsTime = 0;
- this._fpsValue = 60;
- this._showFps = false;
- this._qualityLevel = 2;
-
- // WebSocket for live data — always try auto-detect on startup
- this._ws = null;
- this._liveData = null;
- this._autoDetectLive();
-
- // Input
- this._initKeyboard();
- this._hud.initSettings();
- this._hud.initQuickSelect();
- window.addEventListener('resize', () => this._onResize());
-
- // Start
- this._animate();
- }
-
- // ---- Lighting ----
-
- _setupLighting() {
- this._ambient = new THREE.AmbientLight(0xccccdd, this.settings.ambient * 5.0);
- this._scene.add(this._ambient);
-
- const hemi = new THREE.HemisphereLight(0x6688bb, 0x203040, 1.2);
- this._scene.add(hemi);
-
- const key = new THREE.DirectionalLight(0xffeedd, 1.2);
- key.position.set(4, 8, 3);
- key.castShadow = true;
- key.shadow.mapSize.set(1024, 1024);
- key.shadow.camera.near = 0.5;
- key.shadow.camera.far = 20;
- key.shadow.camera.left = -8;
- key.shadow.camera.right = 8;
- key.shadow.camera.top = 8;
- key.shadow.camera.bottom = -8;
- this._scene.add(key);
-
- // Fill light from opposite side
- const fill = new THREE.DirectionalLight(0x8899bb, 0.7);
- fill.position.set(-4, 5, -2);
- this._scene.add(fill);
-
- // Rim light from above/behind for edge definition
- const rim = new THREE.DirectionalLight(0x6699cc, 0.5);
- rim.position.set(0, 6, -5);
- this._scene.add(rim);
-
- // Overhead room light — general illumination
- const overhead = new THREE.PointLight(0x8899aa, 1.0, 20, 1.0);
- overhead.position.set(0, 3.8, 0);
- this._scene.add(overhead);
- }
-
- // ---- Room ----
-
- _buildRoom() {
- this._grid = new THREE.GridHelper(12, 24, 0x1a4830, 0x0c2818);
- this._grid.material.opacity = 0.5;
- this._grid.material.transparent = true;
- this._scene.add(this._grid);
-
- const boxGeo = new THREE.BoxGeometry(12, 4, 10);
- const edges = new THREE.EdgesGeometry(boxGeo);
- this._roomWire = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({
- color: C.greenDim, opacity: 0.3, transparent: true,
- }));
- this._roomWire.position.y = 2;
- this._scene.add(this._roomWire);
-
- // Reflective floor
- const floorGeo = new THREE.PlaneGeometry(12, 10);
- this._floorMat = new THREE.MeshStandardMaterial({
- color: 0x101810,
- roughness: 1.0 - this.settings.reflect * 0.7,
- metalness: this.settings.reflect * 0.5,
- emissive: 0x020404,
- emissiveIntensity: 0.08,
- });
- const floor = new THREE.Mesh(floorGeo, this._floorMat);
- floor.rotation.x = -Math.PI / 2;
- floor.receiveShadow = true;
- this._scene.add(floor);
-
- // Table under router
- const tableGeo = new THREE.BoxGeometry(0.8, 0.6, 0.5);
- const tableMat = new THREE.MeshStandardMaterial({ color: 0x6b5840, roughness: 0.55, emissive: 0x1a1408, emissiveIntensity: 0.25 });
- const table = new THREE.Mesh(tableGeo, tableMat);
- table.position.set(-4, 0.3, -3);
- table.castShadow = true;
- this._scene.add(table);
- }
-
- // ---- Router ----
-
- _buildRouter() {
- this._routerGroup = new THREE.Group();
- this._routerGroup.position.set(-4, 0.92, -3);
-
- const bodyGeo = new THREE.BoxGeometry(0.6, 0.12, 0.35);
- const bodyMat = new THREE.MeshStandardMaterial({ color: 0x505060, roughness: 0.2, metalness: 0.7, emissive: 0x101018, emissiveIntensity: 0.2 });
- this._routerGroup.add(new THREE.Mesh(bodyGeo, bodyMat));
-
- for (let i = -1; i <= 1; i++) {
- const antGeo = new THREE.CylinderGeometry(0.015, 0.015, 0.35);
- const antMat = new THREE.MeshStandardMaterial({ color: 0x606068, roughness: 0.3, metalness: 0.6, emissive: 0x101018, emissiveIntensity: 0.15 });
- const ant = new THREE.Mesh(antGeo, antMat);
- ant.position.set(i * 0.2, 0.24, 0);
- ant.rotation.z = i * 0.15;
- this._routerGroup.add(ant);
- }
-
- const ledGeo = new THREE.SphereGeometry(0.025);
- this._routerLed = new THREE.Mesh(ledGeo, new THREE.MeshBasicMaterial({ color: C.greenGlow }));
- this._routerLed.position.set(0.22, 0.07, 0.18);
- this._routerGroup.add(this._routerLed);
-
- this._routerLight = new THREE.PointLight(C.blueSignal, 1.2, 8);
- this._routerLight.position.set(0, 0.3, 0);
- this._routerGroup.add(this._routerLight);
-
- this._scene.add(this._routerGroup);
- }
-
- // ---- WiFi Waves ----
-
- _buildWifiWaves() {
- this._wifiWaves = [];
- for (let i = 0; i < 5; i++) {
- const radius = 0.8 + i * 1.0;
- const geo = new THREE.SphereGeometry(radius, 24, 16, 0, Math.PI * 2, 0, Math.PI * 0.6);
- const mat = new THREE.MeshBasicMaterial({
- color: C.blueSignal,
- transparent: true, opacity: 0,
- side: THREE.DoubleSide,
- blending: THREE.AdditiveBlending,
- depthWrite: false, wireframe: true,
- });
- const shell = new THREE.Mesh(geo, mat);
- shell.position.copy(this._routerGroup.position);
- shell.position.y += 0.5;
- this._scene.add(shell);
- this._wifiWaves.push({ mesh: shell, mat, phase: i * 0.7 });
- }
- }
-
- // ========================================
- // DOT MATRIX MIST
- // ========================================
-
- _buildDotMatrixMist() {
- const COUNT = 800;
- const positions = new Float32Array(COUNT * 3);
- const alphas = new Float32Array(COUNT);
- for (let i = 0; i < COUNT; i++) {
- const angle = Math.random() * Math.PI * 2;
- const r = Math.random() * 0.5;
- positions[i * 3] = Math.cos(angle) * r;
- positions[i * 3 + 1] = Math.random() * 1.8;
- positions[i * 3 + 2] = Math.sin(angle) * r;
- alphas[i] = 0;
- }
- const geo = new THREE.BufferGeometry();
- geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
- geo.setAttribute('alpha', new THREE.BufferAttribute(alphas, 1));
- const mat = new THREE.ShaderMaterial({
- vertexShader: `
- attribute float alpha;
- varying float vAlpha;
- void main() {
- vAlpha = alpha;
- vec4 mv = modelViewMatrix * vec4(position, 1.0);
- gl_PointSize = 3.0 * (200.0 / -mv.z);
- gl_Position = projectionMatrix * mv;
- }
- `,
- fragmentShader: `
- uniform vec3 uColor;
- varying float vAlpha;
- void main() {
- float d = length(gl_PointCoord - 0.5);
- if (d > 0.5) discard;
- float edge = smoothstep(0.5, 0.2, d);
- gl_FragColor = vec4(uColor, edge * vAlpha);
- }
- `,
- uniforms: { uColor: { value: new THREE.Color(this.settings.wireColor) } },
- transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
- });
- this._mistPoints = new THREE.Points(geo, mat);
- this._scene.add(this._mistPoints);
- this._mistCount = COUNT;
- }
-
- // ---- Particle Trail ----
-
- _buildParticleTrail() {
- const COUNT = 200;
- const positions = new Float32Array(COUNT * 3);
- const ages = new Float32Array(COUNT);
- for (let i = 0; i < COUNT; i++) ages[i] = 1;
- const geo = new THREE.BufferGeometry();
- geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
- geo.setAttribute('age', new THREE.BufferAttribute(ages, 1));
- const mat = new THREE.ShaderMaterial({
- vertexShader: `
- attribute float age;
- varying float vAge;
- void main() {
- vAge = age;
- vec4 mv = modelViewMatrix * vec4(position, 1.0);
- gl_PointSize = max(1.0, (1.0 - age) * 5.0 * (150.0 / -mv.z));
- gl_Position = projectionMatrix * mv;
- }
- `,
- fragmentShader: `
- uniform vec3 uColor;
- varying float vAge;
- void main() {
- float d = length(gl_PointCoord - 0.5);
- if (d > 0.5) discard;
- float alpha = (1.0 - vAge) * 0.6 * smoothstep(0.5, 0.1, d);
- gl_FragColor = vec4(uColor, alpha);
- }
- `,
- uniforms: { uColor: { value: new THREE.Color(C.greenGlow) } },
- transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
- });
- this._trail = new THREE.Points(geo, mat);
- this._scene.add(this._trail);
- this._trailHead = 0;
- this._trailCount = COUNT;
- this._trailTimer = 0;
- }
-
- // ---- Signal Field ----
-
- _buildSignalField() {
- const gridSize = 20;
- const count = gridSize * gridSize;
- const positions = new Float32Array(count * 3);
- this._fieldColors = new Float32Array(count * 3);
- this._fieldSizes = new Float32Array(count);
- for (let iz = 0; iz < gridSize; iz++) {
- for (let ix = 0; ix < gridSize; ix++) {
- const idx = iz * gridSize + ix;
- positions[idx * 3] = (ix - gridSize / 2) * 0.6;
- positions[idx * 3 + 1] = 0.02;
- positions[idx * 3 + 2] = (iz - gridSize / 2) * 0.5;
- this._fieldSizes[idx] = 8;
- }
- }
- const geo = new THREE.BufferGeometry();
- geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
- geo.setAttribute('color', new THREE.BufferAttribute(this._fieldColors, 3));
- geo.setAttribute('size', new THREE.BufferAttribute(this._fieldSizes, 1));
- this._fieldMat = new THREE.PointsMaterial({
- size: 0.35, vertexColors: true, transparent: true,
- opacity: this.settings.field, blending: THREE.AdditiveBlending,
- depthWrite: false, sizeAttenuation: true,
- });
- this._fieldPoints = new THREE.Points(geo, this._fieldMat);
- this._scene.add(this._fieldPoints);
- }
-
- // ---- Keyboard ----
-
- _initKeyboard() {
- window.addEventListener('keydown', (e) => {
- if (this._hud.settingsOpen) return;
- switch (e.key.toLowerCase()) {
- case 'a':
- this._autopilot = !this._autopilot;
- this._controls.enabled = !this._autopilot;
- break;
- case 'd': this._demoData.cycleScenario(); break;
- case 'f':
- this._showFps = !this._showFps;
- document.getElementById('fps-counter').style.display = this._showFps ? 'block' : 'none';
- break;
- case 's': this._hud.toggleSettings(); break;
- case ' ':
- e.preventDefault();
- this._demoData.paused = !this._demoData.paused;
- break;
- }
- });
- }
-
- // ---- Settings / HUD methods delegated to HudController ----
-
- _applyPostSettings() {
- const pp = this._postProcessing;
- pp._bloomPass.strength = this.settings.bloom;
- pp._bloomPass.radius = this.settings.bloomRadius;
- pp._bloomPass.threshold = this.settings.bloomThresh;
- pp._vignettePass.uniforms.uVignetteStrength.value = this.settings.vignette;
- pp._vignettePass.uniforms.uGrainStrength.value = this.settings.grain;
- pp._vignettePass.uniforms.uChromaticStrength.value = this.settings.chromatic;
- }
-
- _applyColors() {
- const wc = new THREE.Color(this.settings.wireColor);
- const jc = new THREE.Color(this.settings.jointColor);
- this._figurePool.applyColors(wc, jc);
- this._mistPoints.material.uniforms.uColor.value.copy(wc);
- }
-
- // ---- WebSocket live data ----
-
- _autoDetectLive() {
- // Probe sensing server health on same origin, then common ports
- const host = window.location.hostname || 'localhost';
- const candidates = [
- window.location.origin, // same origin (e.g. :3000)
- `http://${host}:8765`, // default WS port
- `http://${host}:3000`, // default HTTP port
- ];
- // Deduplicate
- const unique = [...new Set(candidates)];
-
- const tryNext = (i) => {
- if (i >= unique.length) {
- console.log('[Observatory] No sensing server detected, using demo mode');
- return;
- }
- const base = unique[i];
- fetch(`${base}/health`, { signal: AbortSignal.timeout(1500) })
- .then(r => r.ok ? r.json() : Promise.reject())
- .then(data => {
- if (data && data.status === 'ok') {
- const wsProto = base.startsWith('https') ? 'wss:' : 'ws:';
- const urlObj = new URL(base);
- const wsUrl = `${wsProto}//${urlObj.host}/ws/sensing`;
- console.log('[Observatory] Sensing server detected at', base, '→', wsUrl);
- this.settings.dataSource = 'ws';
- this.settings.wsUrl = wsUrl;
- this._connectWS(wsUrl);
- } else {
- tryNext(i + 1);
- }
- })
- .catch(() => tryNext(i + 1));
- };
- tryNext(0);
- }
-
- _connectWS(url) {
- this._disconnectWS();
- try {
- this._ws = new WebSocket(url);
- this._ws.onopen = () => {
- console.log('[Observatory] WebSocket connected');
- this._hud.updateSourceBadge('ws', this._ws);
- };
- this._ws.onmessage = (evt) => { try { this._liveData = JSON.parse(evt.data); } catch {} };
- this._ws.onclose = () => {
- console.log('[Observatory] WebSocket closed, falling back to demo');
- this._ws = null;
- this.settings.dataSource = 'demo';
- this._hud.updateSourceBadge('demo', null);
- };
- this._ws.onerror = () => {};
- } catch {}
- }
-
- _disconnectWS() {
- if (this._ws) { this._ws.close(); this._ws = null; }
- this._liveData = null;
- }
-
- // ========================================
- // ANIMATION LOOP
- // ========================================
-
- _animate() {
- requestAnimationFrame(() => this._animate());
- const dt = Math.min(this._clock.getDelta(), 0.1);
- const elapsed = this._clock.getElapsedTime();
-
- // Data source
- if (this.settings.dataSource === 'ws' && this._liveData) {
- this._currentData = this._liveData;
- } else {
- this._currentData = this._demoData.update(dt);
- }
- const data = this._currentData;
-
- // Updates
- this._nebula.update(dt, elapsed);
- this._figurePool.update(data, elapsed);
- this._scenarioProps.update(data, this._demoData.currentScenario);
- this._updateDotMatrixMist(data, elapsed);
- this._updateParticleTrail(data, dt, elapsed);
- this._updateWifiWaves(elapsed);
- this._updateSignalField(data);
- this._hud.updateHUD(data, this._demoData);
- this._hud.updateSparkline(data);
-
- // Router LED
- this._routerLed.material.opacity = 0.5 + 0.5 * Math.sin(elapsed * 8);
- this._routerLight.intensity = 0.3 + 0.2 * Math.sin(elapsed * 3);
-
- // Autopilot orbit
- if (this._autopilot) {
- this._autoAngle += dt * this.settings.orbitSpeed;
- const r = 10;
- this._camera.position.set(
- Math.sin(this._autoAngle) * r,
- 4.5 + Math.sin(this._autoAngle * 0.5),
- Math.cos(this._autoAngle) * r
- );
- this._controls.target.set(0, 1.2, 0);
- this._controls.update();
- }
- this._controls.update();
- this._postProcessing.update(elapsed);
- this._postProcessing.render();
- this._updateFPS(dt);
- }
-
-
- // ========================================
- // MIST & TRAIL
- // ========================================
-
- _updateDotMatrixMist(data, elapsed) {
- const persons = data?.persons || [];
- const isPresent = data?.classification?.presence || false;
- const pos = this._mistPoints.geometry.attributes.position;
- const alpha = this._mistPoints.geometry.attributes.alpha;
-
- if (!isPresent || persons.length === 0) {
- for (let i = 0; i < this._mistCount; i++) {
- alpha.array[i] = Math.max(0, alpha.array[i] - 0.02);
- }
- alpha.needsUpdate = true;
- return;
- }
-
- // Follow primary person
- const pp = persons[0].position || [0, 0, 0];
- const px = pp[0] || 0, pz = pp[2] || 0;
- const ms = persons[0].motion_score || 0;
- const pose = persons[0].pose || 'standing';
- const isLying = pose === 'lying' || pose === 'fallen';
- const bodyH = isLying ? 0.4 : 1.7;
- const bodyBaseY = isLying ? (pp[1] || 0) + 0.05 : 0.05;
- const spread = ms > 50 ? 0.6 : 0.4;
-
- for (let i = 0; i < this._mistCount; i++) {
- const drift = Math.sin(elapsed * 0.5 + i * 0.1) * 0.003;
- const angle = (i / this._mistCount) * Math.PI * 2 + elapsed * 0.1;
- const layerT = (i % 20) / 20;
- const layerY = bodyBaseY + layerT * bodyH;
-
- let bodyWidth;
- if (isLying) {
- bodyWidth = 0.25;
- } else {
- bodyWidth = layerT > 0.75 ? 0.15 : (layerT > 0.45 ? 0.25 : 0.18);
- }
- const r = bodyWidth * (0.5 + 0.5 * Math.sin(i * 1.7 + elapsed * 0.3)) * spread;
-
- const tx = px + Math.cos(angle + i * 0.3) * r + drift;
- const tz = pz + Math.sin(angle + i * 0.5) * r * 0.6;
-
- pos.array[i * 3] += (tx - pos.array[i * 3]) * 0.05;
- pos.array[i * 3 + 1] += (layerY - pos.array[i * 3 + 1]) * 0.05;
- pos.array[i * 3 + 2] += (tz - pos.array[i * 3 + 2]) * 0.05;
-
- const targetAlpha = 0.15 + Math.sin(elapsed * 2 + i * 0.5) * 0.08;
- alpha.array[i] += (targetAlpha - alpha.array[i]) * 0.08;
- }
- pos.needsUpdate = true;
- alpha.needsUpdate = true;
- }
-
- _updateParticleTrail(data, dt, elapsed) {
- if (this.settings.trail <= 0) return;
- const persons = data?.persons || [];
- const isPresent = data?.classification?.presence || false;
- const pos = this._trail.geometry.attributes.position;
- const ages = this._trail.geometry.attributes.age;
-
- for (let i = 0; i < this._trailCount; i++) {
- ages.array[i] = Math.min(1, ages.array[i] + dt * 0.8);
- }
-
- // Emit from all active persons
- if (isPresent && persons.length > 0) {
- this._trailTimer += dt;
- const ms = persons[0].motion_score || 0;
- const emitRate = ms > 50 ? 0.02 : 0.08;
-
- if (this._trailTimer >= emitRate) {
- this._trailTimer = 0;
- for (const p of persons) {
- const pp = p.position || [0, 0, 0];
- const idx = this._trailHead;
- pos.array[idx * 3] = (pp[0] || 0) + (Math.random() - 0.5) * 0.15;
- pos.array[idx * 3 + 1] = Math.random() * 1.5 + 0.1;
- pos.array[idx * 3 + 2] = (pp[2] || 0) + (Math.random() - 0.5) * 0.15;
- ages.array[idx] = 0;
- this._trailHead = (this._trailHead + 1) % this._trailCount;
- }
- }
- }
- pos.needsUpdate = true;
- ages.needsUpdate = true;
- }
-
- // ---- WiFi Waves ----
-
- _updateWifiWaves(elapsed) {
- for (const w of this._wifiWaves) {
- const t = (elapsed * 0.8 + w.phase) % 4.5;
- const life = t / 4.5;
- w.mat.opacity = Math.max(0, this.settings.waves * 0.25 * (1 - life));
- const scale = 1 + life * 0.6;
- w.mesh.scale.set(scale, scale, scale);
- w.mesh.rotation.y = elapsed * 0.05;
- }
- }
-
- // ---- Signal Field ----
-
- _updateSignalField(data) {
- const field = data?.signal_field?.values;
- if (!field) return;
- const count = Math.min(field.length, 400);
- for (let i = 0; i < count; i++) {
- const v = field[i] || 0;
- let r, g, b;
- if (v < 0.3) { r = 0; g = v * 1.5; b = v * 0.3; }
- else if (v < 0.6) {
- const t = (v - 0.3) / 0.3;
- r = t * 0.3; g = 0.45 + t * 0.4; b = 0.09 - t * 0.05;
- } else {
- const t = (v - 0.6) / 0.4;
- r = 0.3 + t * 0.7; g = 0.85 - t * 0.2; b = 0.04;
- }
- this._fieldColors[i * 3] = r;
- this._fieldColors[i * 3 + 1] = g;
- this._fieldColors[i * 3 + 2] = b;
- this._fieldSizes[i] = 5 + v * 15;
- }
- this._fieldPoints.geometry.attributes.color.needsUpdate = true;
- this._fieldPoints.geometry.attributes.size.needsUpdate = true;
- }
-
- // ---- FPS ----
-
- _updateFPS(dt) {
- this._fpsFrames++;
- this._fpsTime += dt;
- if (this._fpsTime >= 1) {
- this._fpsValue = Math.round(this._fpsFrames / this._fpsTime);
- this._fpsFrames = 0;
- this._fpsTime = 0;
- if (this._showFps) {
- document.getElementById('fps-counter').textContent = `${this._fpsValue} FPS`;
- }
- this._adaptQuality();
- }
- }
-
- _adaptQuality() {
- let nl = this._qualityLevel;
- if (this._fpsValue < 25 && nl > 0) nl--;
- else if (this._fpsValue > 55 && nl < 2) nl++;
- if (nl !== this._qualityLevel) {
- this._qualityLevel = nl;
- this._nebula.setQuality(nl);
- this._postProcessing.setQuality(nl);
- }
- }
-
- _onResize() {
- const w = window.innerWidth, h = window.innerHeight;
- this._camera.aspect = w / h;
- this._camera.updateProjectionMatrix();
- this._renderer.setSize(w, h);
- this._postProcessing.resize(w, h);
- }
-}
-
-new Observatory();
diff --git a/observatory/js/js/nebula-background.js b/observatory/js/js/nebula-background.js
deleted file mode 100644
index 98ad7e84..00000000
--- a/observatory/js/js/nebula-background.js
+++ /dev/null
@@ -1,115 +0,0 @@
-/**
- * Room Atmosphere Background — Warm dark gradient with subtle particles
- * Matches RuView Foundation aesthetic: deep blue-black with warm undertones
- */
-import * as THREE from 'three';
-
-const BG_VERTEX = `
-varying vec3 vWorldPos;
-void main() {
- vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
-}
-`;
-
-const BG_FRAGMENT = `
-uniform float uTime;
-uniform float uOctaves;
-varying vec3 vWorldPos;
-
-vec3 hash33(vec3 p) {
- p = fract(p * vec3(443.8975, 397.2973, 491.1871));
- p += dot(p, p.yxz + 19.19);
- return fract(vec3(p.x * p.y, p.y * p.z, p.z * p.x));
-}
-
-float noise3d(vec3 p) {
- vec3 i = floor(p);
- vec3 f = fract(p);
- f = f * f * (3.0 - 2.0 * f);
- float n = mix(
- mix(mix(dot(hash33(i), f), dot(hash33(i + vec3(1,0,0)), f - vec3(1,0,0)), f.x),
- mix(dot(hash33(i + vec3(0,1,0)), f - vec3(0,1,0)), dot(hash33(i + vec3(1,1,0)), f - vec3(1,1,0)), f.x), f.y),
- mix(mix(dot(hash33(i + vec3(0,0,1)), f - vec3(0,0,1)), dot(hash33(i + vec3(1,0,1)), f - vec3(1,0,1)), f.x),
- mix(dot(hash33(i + vec3(0,1,1)), f - vec3(0,1,1)), dot(hash33(i + vec3(1,1,1)), f - vec3(1,1,1)), f.x), f.y),
- f.z);
- return n * 0.5 + 0.5;
-}
-
-float fbm(vec3 p, float octaves) {
- float v = 0.0, a = 0.5;
- for (float i = 0.0; i < 5.0; i++) {
- if (i >= octaves) break;
- v += a * noise3d(p);
- p *= 2.0;
- a *= 0.5;
- }
- return v;
-}
-
-void main() {
- vec3 dir = normalize(vWorldPos);
-
- // Warm dark atmosphere with subtle color variation
- float n1 = fbm(dir * 2.5 + uTime * 0.008, uOctaves);
- float n2 = fbm(dir * 4.0 - uTime * 0.005, max(1.0, uOctaves - 1.0));
-
- // Foundation palette: deep blue-black with warm undertones
- vec3 deepBlack = vec3(0.03, 0.04, 0.06);
- vec3 warmNavy = vec3(0.04, 0.05, 0.10);
- vec3 greenTint = vec3(0.01, 0.06, 0.04);
-
- vec3 bg = mix(deepBlack, warmNavy, n1 * 0.5);
- bg = mix(bg, greenTint, n2 * 0.15);
-
- // Subtle top-down gradient (lighter ceiling)
- float upFactor = max(0.0, dir.y) * 0.08;
- bg += vec3(0.02, 0.03, 0.05) * upFactor;
-
- // Very subtle dim stars (distant)
- vec3 c = floor(dir * 200.0);
- vec3 h = hash33(c);
- float star = step(0.998, h.x) * h.y * 0.15;
- star *= 0.7 + 0.3 * sin(uTime * 1.5 + h.z * 80.0);
- bg += vec3(0.6, 0.7, 0.8) * star;
-
- gl_FragColor = vec4(bg, 1.0);
-}
-`;
-
-export class NebulaBackground {
- constructor(scene) {
- this._octaves = 4;
-
- this.uniforms = {
- uTime: { value: 0 },
- uOctaves: { value: this._octaves },
- };
-
- const geo = new THREE.SphereGeometry(150, 32, 32);
- const mat = new THREE.ShaderMaterial({
- vertexShader: BG_VERTEX,
- fragmentShader: BG_FRAGMENT,
- uniforms: this.uniforms,
- side: THREE.BackSide,
- depthWrite: false,
- });
-
- this.mesh = new THREE.Mesh(geo, mat);
- scene.add(this.mesh);
- }
-
- update(dt, elapsed) {
- this.uniforms.uTime.value = elapsed;
- }
-
- setQuality(level) {
- this._octaves = [2, 3, 4][level] || 4;
- this.uniforms.uOctaves.value = this._octaves;
- }
-
- dispose() {
- this.mesh.geometry.dispose();
- this.mesh.material.dispose();
- }
-}
diff --git a/observatory/js/js/phase-constellation.js b/observatory/js/js/phase-constellation.js
deleted file mode 100644
index c38444dd..00000000
--- a/observatory/js/js/phase-constellation.js
+++ /dev/null
@@ -1,170 +0,0 @@
-/**
- * Module D — "The Phase Constellation"
- * I/Q star map with constellation lines and rotating temporal view
- */
-import * as THREE from 'three';
-
-const NUM_SUBCARRIERS = 64;
-
-export class PhaseConstellation {
- constructor(scene, panelGroup) {
- this.group = new THREE.Group();
- if (panelGroup) panelGroup.add(this.group);
- else scene.add(this.group);
-
- // Star points (current frame)
- const starGeo = new THREE.BufferGeometry();
- this._positions = new Float32Array(NUM_SUBCARRIERS * 3);
- this._colors = new Float32Array(NUM_SUBCARRIERS * 3);
- this._sizes = new Float32Array(NUM_SUBCARRIERS);
-
- starGeo.setAttribute('position', new THREE.BufferAttribute(this._positions, 3));
- starGeo.setAttribute('color', new THREE.BufferAttribute(this._colors, 3));
- starGeo.setAttribute('size', new THREE.BufferAttribute(this._sizes, 1));
-
- const starMat = new THREE.PointsMaterial({
- size: 0.12,
- vertexColors: true,
- transparent: true,
- opacity: 0.9,
- blending: THREE.AdditiveBlending,
- depthWrite: false,
- sizeAttenuation: true,
- });
- this._stars = new THREE.Points(starGeo, starMat);
- this.group.add(this._stars);
-
- // Ghost layer (previous frame)
- const ghostGeo = new THREE.BufferGeometry();
- this._ghostPos = new Float32Array(NUM_SUBCARRIERS * 3);
- ghostGeo.setAttribute('position', new THREE.BufferAttribute(this._ghostPos, 3));
-
- const ghostMat = new THREE.PointsMaterial({
- color: 0x00d4ff,
- size: 0.06,
- transparent: true,
- opacity: 0.2,
- blending: THREE.AdditiveBlending,
- depthWrite: false,
- sizeAttenuation: true,
- });
- this._ghosts = new THREE.Points(ghostGeo, ghostMat);
- this.group.add(this._ghosts);
-
- // Constellation lines (connecting adjacent subcarriers)
- const lineGeo = new THREE.BufferGeometry();
- this._linePos = new Float32Array(NUM_SUBCARRIERS * 2 * 3); // pairs
- lineGeo.setAttribute('position', new THREE.BufferAttribute(this._linePos, 3));
-
- const lineMat = new THREE.LineBasicMaterial({
- color: 0x00d4ff,
- transparent: true,
- opacity: 0.15,
- blending: THREE.AdditiveBlending,
- depthWrite: false,
- });
- this._lines = new THREE.LineSegments(lineGeo, lineMat);
- this.group.add(this._lines);
-
- // Axes
- this._addAxes();
-
- this._prevIQ = null;
- }
-
- _addAxes() {
- const axesMat = new THREE.LineBasicMaterial({
- color: 0x00d4ff,
- transparent: true,
- opacity: 0.1,
- });
-
- // I axis
- const iGeo = new THREE.BufferGeometry().setFromPoints([
- new THREE.Vector3(-2.5, 0, 0),
- new THREE.Vector3(2.5, 0, 0),
- ]);
- this.group.add(new THREE.Line(iGeo, axesMat));
-
- // Q axis
- const qGeo = new THREE.BufferGeometry().setFromPoints([
- new THREE.Vector3(0, -2.5, 0),
- new THREE.Vector3(0, 2.5, 0),
- ]);
- this.group.add(new THREE.Line(qGeo, axesMat));
- }
-
- update(dt, elapsed, data) {
- const iq = data?._observatory?.subcarrier_iq;
- const variance = data?._observatory?.per_subcarrier_variance;
- const amplitude = data?.nodes?.[0]?.amplitude;
-
- // Slow Y rotation for temporal evolution
- this.group.rotation.y = elapsed * 0.05;
-
- if (!iq || iq.length < NUM_SUBCARRIERS) return;
-
- // Copy current to ghost
- this._ghostPos.set(this._positions);
- this._ghosts.geometry.attributes.position.needsUpdate = true;
-
- // Update current positions from I/Q
- for (let s = 0; s < NUM_SUBCARRIERS; s++) {
- const i3 = s * 3;
- const iVal = (iq[s]?.i || 0) * 4; // scale for visibility
- const qVal = (iq[s]?.q || 0) * 4;
-
- this._positions[i3] = iVal;
- this._positions[i3 + 1] = qVal;
- this._positions[i3 + 2] = 0;
-
- // Size from amplitude
- const amp = amplitude ? (amplitude[s % amplitude.length] || 0.1) : 0.1;
- this._sizes[s] = 0.06 + amp * 0.15;
-
- // Color from variance: blue(low) -> amber(high)
- const v = variance ? Math.min(1, (variance[s] || 0) * 2) : 0;
- this._colors[i3] = v * 1.0; // R
- this._colors[i3 + 1] = 0.5 + v * 0.3; // G
- this._colors[i3 + 2] = 1.0 - v * 0.7; // B
- }
-
- this._stars.geometry.attributes.position.needsUpdate = true;
- this._stars.geometry.attributes.color.needsUpdate = true;
- this._stars.geometry.attributes.size.needsUpdate = true;
-
- // Update constellation lines
- for (let s = 0; s < NUM_SUBCARRIERS - 1; s++) {
- const li = s * 6;
- const i3a = s * 3;
- const i3b = (s + 1) * 3;
-
- this._linePos[li] = this._positions[i3a];
- this._linePos[li + 1] = this._positions[i3a + 1];
- this._linePos[li + 2] = this._positions[i3a + 2];
- this._linePos[li + 3] = this._positions[i3b];
- this._linePos[li + 4] = this._positions[i3b + 1];
- this._linePos[li + 5] = this._positions[i3b + 2];
- }
- // Last pair: wrap around
- const lastLi = (NUM_SUBCARRIERS - 1) * 6;
- const lastI3 = (NUM_SUBCARRIERS - 1) * 3;
- this._linePos[lastLi] = this._positions[lastI3];
- this._linePos[lastLi + 1] = this._positions[lastI3 + 1];
- this._linePos[lastLi + 2] = this._positions[lastI3 + 2];
- this._linePos[lastLi + 3] = this._positions[0];
- this._linePos[lastLi + 4] = this._positions[1];
- this._linePos[lastLi + 5] = this._positions[2];
-
- this._lines.geometry.attributes.position.needsUpdate = true;
- }
-
- dispose() {
- this._stars.geometry.dispose();
- this._stars.material.dispose();
- this._ghosts.geometry.dispose();
- this._ghosts.material.dispose();
- this._lines.geometry.dispose();
- this._lines.material.dispose();
- }
-}
diff --git a/observatory/js/js/post-processing.js b/observatory/js/js/post-processing.js
deleted file mode 100644
index 02186e38..00000000
--- a/observatory/js/js/post-processing.js
+++ /dev/null
@@ -1,125 +0,0 @@
-/**
- * Post-Processing — Subtle bloom for green glow wireframe,
- * warm vignette, minimal grain. Foundation-style.
- */
-import * as THREE from 'three';
-import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
-import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
-import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
-import { ShaderPass } from 'three/addons/postprocessing/ShaderPass.js';
-
-const VignetteShader = {
- uniforms: {
- tDiffuse: { value: null },
- uTime: { value: 0 },
- uVignetteStrength: { value: 0.5 },
- uChromaticStrength: { value: 0.0015 },
- uGrainStrength: { value: 0.03 },
- uWarmth: { value: 0.08 },
- },
- vertexShader: `
- varying vec2 vUv;
- void main() {
- vUv = uv;
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
- }
- `,
- fragmentShader: `
- uniform sampler2D tDiffuse;
- uniform float uTime;
- uniform float uVignetteStrength;
- uniform float uChromaticStrength;
- uniform float uGrainStrength;
- uniform float uWarmth;
- varying vec2 vUv;
-
- float rand(vec2 co) {
- return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
- }
-
- void main() {
- vec2 uv = vUv;
- vec2 center = uv - 0.5;
- float dist = length(center);
-
- // Subtle chromatic aberration at edges only
- vec2 offset = center * dist * uChromaticStrength;
- float r = texture2D(tDiffuse, uv + offset).r;
- float g = texture2D(tDiffuse, uv).g;
- float b = texture2D(tDiffuse, uv - offset * 0.5).b;
- vec3 color = vec3(r, g, b);
-
- // Warm vignette
- float vignette = 1.0 - dist * dist * uVignetteStrength * 1.8;
- color *= vignette;
-
- // Very subtle warm shift in shadows
- float luma = dot(color, vec3(0.299, 0.587, 0.114));
- color.r += (1.0 - luma) * uWarmth * 0.5;
- color.g += (1.0 - luma) * uWarmth * 0.2;
-
- // Minimal grain
- float grain = (rand(uv * uTime * 0.01) - 0.5) * uGrainStrength;
- color += grain;
-
- gl_FragColor = vec4(color, 1.0);
- }
- `,
-};
-
-export class PostProcessing {
- constructor(renderer, scene, camera) {
- const size = renderer.getSize(new THREE.Vector2());
-
- this.composer = new EffectComposer(renderer);
- this.composer.addPass(new RenderPass(scene, camera));
-
- // Bloom — tuned for green wireframe glow
- this._bloomPass = new UnrealBloomPass(
- new THREE.Vector2(size.x, size.y),
- 0.08, // strength — subtle glow, overridden by settings
- 0.2, // radius
- 0.6 // threshold
- );
- this.composer.addPass(this._bloomPass);
-
- // Vignette + warmth
- this._vignettePass = new ShaderPass(VignetteShader);
- this.composer.addPass(this._vignettePass);
-
- this._bloomEnabled = true;
- }
-
- update(elapsed) {
- this._vignettePass.uniforms.uTime.value = elapsed;
- }
-
- render() {
- this.composer.render();
- }
-
- resize(width, height) {
- this.composer.setSize(width, height);
- this._bloomPass.resolution.set(width, height);
- }
-
- setQuality(level) {
- if (level === 0) {
- this._bloomPass.strength = 0;
- this._vignettePass.uniforms.uChromaticStrength.value = 0;
- this._vignettePass.uniforms.uGrainStrength.value = 0;
- } else if (level === 1) {
- this._bloomPass.strength = 0.6;
- this._vignettePass.uniforms.uChromaticStrength.value = 0.001;
- this._vignettePass.uniforms.uGrainStrength.value = 0.02;
- } else {
- this._bloomPass.strength = 1.0;
- this._vignettePass.uniforms.uChromaticStrength.value = 0.0015;
- this._vignettePass.uniforms.uGrainStrength.value = 0.03;
- }
- }
-
- dispose() {
- this.composer.dispose();
- }
-}
diff --git a/observatory/js/js/presence-cartography.js b/observatory/js/js/presence-cartography.js
deleted file mode 100644
index 37c6e339..00000000
--- a/observatory/js/js/presence-cartography.js
+++ /dev/null
@@ -1,178 +0,0 @@
-/**
- * Module C — "Presence Cartography"
- * InstancedMesh 20x4x20 voxel heatmap with person lights
- */
-import * as THREE from 'three';
-
-const GRID_X = 20;
-const GRID_Y = 4;
-const GRID_Z = 20;
-const TOTAL_VOXELS = GRID_X * GRID_Y * GRID_Z;
-const VOXEL_SIZE = 0.22;
-
-export class PresenceCartography {
- constructor(scene, panelGroup) {
- this.group = new THREE.Group();
- if (panelGroup) panelGroup.add(this.group);
- else scene.add(this.group);
-
- // Instanced cubes
- const cubeGeo = new THREE.BoxGeometry(VOXEL_SIZE, VOXEL_SIZE, VOXEL_SIZE);
- const cubeMat = new THREE.MeshBasicMaterial({
- color: 0xffffff,
- transparent: true,
- opacity: 1,
- blending: THREE.AdditiveBlending,
- depthWrite: false,
- });
-
- this._mesh = new THREE.InstancedMesh(cubeGeo, cubeMat, TOTAL_VOXELS);
- this._mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
-
- // Color attribute
- this._colors = new Float32Array(TOTAL_VOXELS * 3);
- this._mesh.instanceColor = new THREE.InstancedBufferAttribute(this._colors, 3);
-
- // Initialize positions
- const dummy = new THREE.Object3D();
- const halfX = (GRID_X * VOXEL_SIZE * 1.1) / 2;
- const halfZ = (GRID_Z * VOXEL_SIZE * 1.1) / 2;
-
- for (let y = 0; y < GRID_Y; y++) {
- for (let z = 0; z < GRID_Z; z++) {
- for (let x = 0; x < GRID_X; x++) {
- const idx = y * GRID_Z * GRID_X + z * GRID_X + x;
- dummy.position.set(
- x * VOXEL_SIZE * 1.1 - halfX,
- y * VOXEL_SIZE * 1.1,
- z * VOXEL_SIZE * 1.1 - halfZ
- );
- dummy.scale.set(0.01, 0.01, 0.01); // start invisible
- dummy.updateMatrix();
- this._mesh.setMatrixAt(idx, dummy.matrix);
-
- this._colors[idx * 3] = 0;
- this._colors[idx * 3 + 1] = 0.2;
- this._colors[idx * 3 + 2] = 0.4;
- }
- }
- }
- this._mesh.instanceMatrix.needsUpdate = true;
- this._mesh.instanceColor.needsUpdate = true;
- this.group.add(this._mesh);
-
- // Room wireframe
- const roomW = GRID_X * VOXEL_SIZE * 1.1;
- const roomH = GRID_Y * VOXEL_SIZE * 1.1;
- const roomD = GRID_Z * VOXEL_SIZE * 1.1;
- const boxGeo = new THREE.BoxGeometry(roomW, roomH, roomD);
- const edges = new THREE.EdgesGeometry(boxGeo);
- const lineMat = new THREE.LineBasicMaterial({
- color: 0x00d4ff,
- transparent: true,
- opacity: 0.15,
- });
- const wireframe = new THREE.LineSegments(edges, lineMat);
- wireframe.position.y = roomH / 2;
- this.group.add(wireframe);
-
- // Person lights (up to 4)
- this._personLights = [];
- for (let i = 0; i < 4; i++) {
- const light = new THREE.PointLight(0xff8800, 0, 3);
- this.group.add(light);
- this._personLights.push(light);
- }
-
- this._dummy = new THREE.Object3D();
- this._halfX = halfX;
- this._halfZ = halfZ;
- }
-
- update(dt, elapsed, data) {
- const field = data?.signal_field?.values;
- const persons = data?.persons || [];
-
- const dummy = this._dummy;
-
- if (field && field.length >= GRID_X * GRID_Z) {
- for (let y = 0; y < GRID_Y; y++) {
- for (let z = 0; z < GRID_Z; z++) {
- for (let x = 0; x < GRID_X; x++) {
- const idx = y * GRID_Z * GRID_X + z * GRID_X + x;
- const fieldIdx = z * GRID_X + x;
- const val = field[fieldIdx] || 0;
-
- // Extrude vertically: layer 0 = full val, higher layers diminish
- const layerFactor = Math.max(0, 1 - y / GRID_Y);
- const v = val * layerFactor;
-
- // Scale voxel by value
- const s = v > 0.05 ? 0.3 + v * 0.7 : 0.01;
- dummy.position.set(
- x * VOXEL_SIZE * 1.1 - this._halfX,
- y * VOXEL_SIZE * 1.1,
- z * VOXEL_SIZE * 1.1 - this._halfZ
- );
- dummy.scale.set(s, s, s);
- dummy.updateMatrix();
- this._mesh.setMatrixAt(idx, dummy.matrix);
-
- // Color: blue(low) -> cyan(mid) -> amber(high)
- let r, g, b;
- if (v < 0.3) {
- const t = v / 0.3;
- r = 0.02;
- g = 0.06 + t * 0.6;
- b = 0.2 + t * 0.6;
- } else if (v < 0.6) {
- const t = (v - 0.3) / 0.3;
- r = t * 0.8;
- g = 0.66 + t * 0.2;
- b = 0.8 - t * 0.5;
- } else {
- const t = (v - 0.6) / 0.4;
- r = 0.8 + t * 0.2;
- g = 0.86 - t * 0.5;
- b = 0.3 - t * 0.3;
- }
- this._colors[idx * 3] = r;
- this._colors[idx * 3 + 1] = g;
- this._colors[idx * 3 + 2] = b;
- }
- }
- }
- this._mesh.instanceMatrix.needsUpdate = true;
- this._mesh.instanceColor.needsUpdate = true;
- }
-
- // Person lights
- for (let i = 0; i < this._personLights.length; i++) {
- const light = this._personLights[i];
- if (i < persons.length) {
- const p = persons[i].position || [0, 0, 0];
- light.position.set(p[0] * 2, 1.5, p[2] * 2);
- light.intensity = 1.5 + Math.sin(elapsed * 3 + i) * 0.5;
- light.color.setHex(0xff8800);
- } else {
- light.intensity = 0;
- }
- }
- }
-
- /** Reduce voxel count for performance */
- setQuality(level) {
- // For now just toggle visibility of upper layers
- // level 0 = show only ground, 2 = show all
- this._mesh.count = level === 0
- ? GRID_X * GRID_Z
- : level === 1
- ? GRID_X * GRID_Z * 2
- : TOTAL_VOXELS;
- }
-
- dispose() {
- this._mesh.geometry.dispose();
- this._mesh.material.dispose();
- }
-}
diff --git a/observatory/js/js/subcarrier-manifold.js b/observatory/js/js/subcarrier-manifold.js
deleted file mode 100644
index b410aa1a..00000000
--- a/observatory/js/js/subcarrier-manifold.js
+++ /dev/null
@@ -1,163 +0,0 @@
-/**
- * Module A — "The Subcarrier Manifold"
- * 3D scrolling surface: 64 subcarriers x 60 time slots
- */
-import * as THREE from 'three';
-
-const MANIFOLD_VERTEX = `
-attribute float aHeight;
-attribute float aAge; // 0 = newest, 1 = oldest
-varying float vHeight;
-varying float vAge;
-void main() {
- vec3 pos = position;
- pos.y += aHeight * 2.0;
- vHeight = aHeight;
- vAge = aAge;
- gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
-}
-`;
-
-const MANIFOLD_FRAGMENT = `
-uniform float uTime;
-varying float vHeight;
-varying float vAge;
-void main() {
- // Color map: low=deep blue, mid=cyan, high=amber
- vec3 lo = vec3(0.02, 0.06, 0.2);
- vec3 mid = vec3(0.0, 0.83, 1.0);
- vec3 hi = vec3(1.0, 0.53, 0.0);
-
- float h = clamp(vHeight, 0.0, 1.0);
- vec3 col = h < 0.5
- ? mix(lo, mid, h * 2.0)
- : mix(mid, hi, (h - 0.5) * 2.0);
-
- // Fade older rows
- float alpha = 0.3 + 0.7 * (1.0 - vAge);
- gl_FragColor = vec4(col, alpha);
-}
-`;
-
-const SUBS = 64;
-const TIME_SLOTS = 60;
-
-export class SubcarrierManifold {
- constructor(scene, panelGroup) {
- this.group = new THREE.Group();
- if (panelGroup) panelGroup.add(this.group);
- else scene.add(this.group);
-
- this._history = []; // ring buffer of Float32Array[64]
- for (let i = 0; i < TIME_SLOTS; i++) {
- this._history.push(new Float32Array(SUBS));
- }
- this._head = 0;
-
- // Build surface geometry
- const geo = new THREE.PlaneGeometry(8, 5, SUBS - 1, TIME_SLOTS - 1);
- const vertCount = SUBS * TIME_SLOTS;
-
- this._heights = new Float32Array(vertCount);
- this._ages = new Float32Array(vertCount);
- for (let t = 0; t < TIME_SLOTS; t++) {
- for (let s = 0; s < SUBS; s++) {
- this._ages[t * SUBS + s] = t / TIME_SLOTS;
- }
- }
-
- geo.setAttribute('aHeight', new THREE.BufferAttribute(this._heights, 1));
- geo.setAttribute('aAge', new THREE.BufferAttribute(this._ages, 1));
-
- // Solid surface
- const mat = new THREE.ShaderMaterial({
- vertexShader: MANIFOLD_VERTEX,
- fragmentShader: MANIFOLD_FRAGMENT,
- uniforms: { uTime: { value: 0 } },
- transparent: true,
- side: THREE.DoubleSide,
- depthWrite: false,
- blending: THREE.AdditiveBlending,
- });
- this._mesh = new THREE.Mesh(geo, mat);
- this._mesh.rotation.x = -Math.PI * 0.35;
- this.group.add(this._mesh);
-
- // Wireframe overlay
- const wireGeo = geo.clone();
- wireGeo.setAttribute('aHeight', new THREE.BufferAttribute(this._heights, 1));
- wireGeo.setAttribute('aAge', new THREE.BufferAttribute(this._ages, 1));
- const wireMat = new THREE.ShaderMaterial({
- vertexShader: MANIFOLD_VERTEX,
- fragmentShader: `
- varying float vHeight;
- varying float vAge;
- void main() {
- float alpha = 0.15 * (1.0 - vAge);
- gl_FragColor = vec4(0.0, 0.83, 1.0, alpha);
- }
- `,
- uniforms: { uTime: { value: 0 } },
- transparent: true,
- wireframe: true,
- depthWrite: false,
- blending: THREE.AdditiveBlending,
- });
- this._wire = new THREE.Mesh(wireGeo, wireMat);
- this._wire.rotation.x = -Math.PI * 0.35;
- this.group.add(this._wire);
-
- this._frameAccum = 0;
- this._pushInterval = 1 / 15; // push ~15 rows/sec
- }
-
- update(dt, elapsed, data) {
- this._mesh.material.uniforms.uTime.value = elapsed;
-
- // Push new amplitude data at regular intervals
- this._frameAccum += dt;
- if (this._frameAccum >= this._pushInterval && data) {
- this._frameAccum = 0;
-
- const amp = data.nodes?.[0]?.amplitude;
- const row = new Float32Array(SUBS);
- if (amp && amp.length > 0) {
- for (let i = 0; i < SUBS; i++) {
- row[i] = amp[i % amp.length] || 0;
- }
- }
-
- this._history[this._head] = row;
- this._head = (this._head + 1) % TIME_SLOTS;
-
- this._rebuildHeights();
- }
- }
-
- _rebuildHeights() {
- for (let t = 0; t < TIME_SLOTS; t++) {
- const histIdx = (this._head + t) % TIME_SLOTS;
- const row = this._history[histIdx];
- for (let s = 0; s < SUBS; s++) {
- const idx = t * SUBS + s;
- this._heights[idx] = row[s];
- this._ages[idx] = t / TIME_SLOTS;
- }
- }
-
- const geo = this._mesh.geometry;
- geo.attributes.aHeight.needsUpdate = true;
- geo.attributes.aAge.needsUpdate = true;
-
- const wGeo = this._wire.geometry;
- wGeo.attributes.aHeight.needsUpdate = true;
- wGeo.attributes.aAge.needsUpdate = true;
- }
-
- dispose() {
- this._mesh.geometry.dispose();
- this._mesh.material.dispose();
- this._wire.geometry.dispose();
- this._wire.material.dispose();
- }
-}
diff --git a/observatory/js/js/vitals-oracle.js b/observatory/js/js/vitals-oracle.js
deleted file mode 100644
index e061f800..00000000
--- a/observatory/js/js/vitals-oracle.js
+++ /dev/null
@@ -1,187 +0,0 @@
-/**
- * Module B — "Vital Signs Oracle"
- * Breathing/HR as orbital torus rings with beat markers + trail particles
- */
-import * as THREE from 'three';
-
-export class VitalsOracle {
- constructor(scene, panelGroup) {
- this.group = new THREE.Group();
- if (panelGroup) panelGroup.add(this.group);
- else scene.add(this.group);
-
- // Outer torus — breathing (violet)
- const breathGeo = new THREE.TorusGeometry(1.8, 0.06, 16, 64);
- this._breathMat = new THREE.MeshBasicMaterial({
- color: 0x8844ff,
- transparent: true,
- opacity: 0.7,
- blending: THREE.AdditiveBlending,
- depthWrite: false,
- });
- this._breathRing = new THREE.Mesh(breathGeo, this._breathMat);
- this._breathRing.rotation.x = Math.PI * 0.4;
- this.group.add(this._breathRing);
-
- // Inner torus — heart rate (crimson)
- const hrGeo = new THREE.TorusGeometry(1.2, 0.04, 16, 64);
- this._hrMat = new THREE.MeshBasicMaterial({
- color: 0xff2244,
- transparent: true,
- opacity: 0.6,
- blending: THREE.AdditiveBlending,
- depthWrite: false,
- });
- this._hrRing = new THREE.Mesh(hrGeo, this._hrMat);
- this._hrRing.rotation.x = Math.PI * 0.5;
- this._hrRing.rotation.z = Math.PI * 0.15;
- this.group.add(this._hrRing);
-
- // Center orb
- const orbGeo = new THREE.SphereGeometry(0.35, 24, 24);
- this._orbMat = new THREE.MeshBasicMaterial({
- color: 0x00d4ff,
- transparent: true,
- opacity: 0.5,
- blending: THREE.AdditiveBlending,
- });
- this._orb = new THREE.Mesh(orbGeo, this._orbMat);
- this.group.add(this._orb);
-
- // Bloom point light
- this._light = new THREE.PointLight(0x00d4ff, 1.5, 8);
- this.group.add(this._light);
-
- // Trail particles along breathing ring
- const trailCount = 120;
- const trailGeo = new THREE.BufferGeometry();
- const trailPos = new Float32Array(trailCount * 3);
- const trailSizes = new Float32Array(trailCount);
- for (let i = 0; i < trailCount; i++) {
- const angle = (i / trailCount) * Math.PI * 2;
- trailPos[i * 3] = Math.cos(angle) * 1.8;
- trailPos[i * 3 + 1] = 0;
- trailPos[i * 3 + 2] = Math.sin(angle) * 1.8;
- trailSizes[i] = 3;
- }
- trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPos, 3));
- trailGeo.setAttribute('size', new THREE.BufferAttribute(trailSizes, 1));
-
- const trailMat = new THREE.PointsMaterial({
- color: 0x8844ff,
- size: 0.08,
- transparent: true,
- opacity: 0.4,
- blending: THREE.AdditiveBlending,
- depthWrite: false,
- sizeAttenuation: true,
- });
- this._trails = new THREE.Points(trailGeo, trailMat);
- this._trails.rotation.x = Math.PI * 0.4;
- this.group.add(this._trails);
-
- // Beat flash sprites
- this._beatFlash = this._createBeatSprite(0xff2244);
- this.group.add(this._beatFlash);
- this._beatTimer = 0;
- this._lastBeatTime = 0;
-
- // State
- this._breathBpm = 0;
- this._hrBpm = 0;
- this._breathConf = 0;
- this._hrConf = 0;
- }
-
- _createBeatSprite(color) {
- const canvas = document.createElement('canvas');
- canvas.width = 64;
- canvas.height = 64;
- const ctx = canvas.getContext('2d');
- const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
- gradient.addColorStop(0, `rgba(255, 34, 68, 1)`);
- gradient.addColorStop(0.3, `rgba(255, 34, 68, 0.5)`);
- gradient.addColorStop(1, `rgba(255, 34, 68, 0)`);
- ctx.fillStyle = gradient;
- ctx.fillRect(0, 0, 64, 64);
-
- const tex = new THREE.CanvasTexture(canvas);
- const mat = new THREE.SpriteMaterial({
- map: tex,
- transparent: true,
- blending: THREE.AdditiveBlending,
- depthWrite: false,
- });
- const sprite = new THREE.Sprite(mat);
- sprite.scale.set(0, 0, 0);
- return sprite;
- }
-
- update(dt, elapsed, data) {
- const vs = data?.vital_signs || {};
- this._breathBpm = vs.breathing_rate_bpm || 0;
- this._hrBpm = vs.heart_rate_bpm || 0;
- this._breathConf = vs.breathing_confidence || 0;
- this._hrConf = vs.heart_rate_confidence || 0;
-
- // Breathing ring pulsation
- const breathFreq = this._breathBpm / 60;
- const breathPulse = breathFreq > 0 ? Math.sin(elapsed * Math.PI * 2 * breathFreq) : 0;
- const breathScale = 1.0 + breathPulse * 0.08 * this._breathConf;
- this._breathRing.scale.set(breathScale, breathScale, 1);
- this._breathMat.opacity = 0.3 + this._breathConf * 0.5;
-
- // HR ring pulsation (faster)
- const hrFreq = this._hrBpm / 60;
- const hrPulse = hrFreq > 0 ? Math.sin(elapsed * Math.PI * 2 * hrFreq) : 0;
- const hrScale = 1.0 + hrPulse * 0.06 * this._hrConf;
- this._hrRing.scale.set(hrScale, hrScale, 1);
- this._hrMat.opacity = 0.2 + this._hrConf * 0.5;
-
- // Slow rotation
- this._breathRing.rotation.z = elapsed * 0.1;
- this._hrRing.rotation.z = -elapsed * 0.15;
- this._trails.rotation.z = elapsed * 0.1;
-
- // Center orb pulse
- const orbPulse = 1.0 + breathPulse * 0.1;
- this._orb.scale.set(orbPulse, orbPulse, orbPulse);
- this._light.intensity = 0.8 + Math.abs(breathPulse) * 1.0;
-
- // Beat flash on HR cycle
- if (hrFreq > 0) {
- this._beatTimer += dt;
- const beatInterval = 1 / hrFreq;
- if (this._beatTimer >= beatInterval) {
- this._beatTimer -= beatInterval;
- this._lastBeatTime = elapsed;
- }
- const beatAge = elapsed - this._lastBeatTime;
- const flashSize = Math.max(0, 1.2 - beatAge * 4) * this._hrConf;
- this._beatFlash.scale.set(flashSize, flashSize, 1);
- } else {
- this._beatFlash.scale.set(0, 0, 0);
- }
-
- // Update trail particle sizes based on breathing
- const sizes = this._trails.geometry.attributes.size;
- if (sizes) {
- for (let i = 0; i < sizes.count; i++) {
- const phase = (i / sizes.count) * Math.PI * 2 + elapsed * breathFreq * Math.PI * 2;
- sizes.array[i] = 0.04 + Math.abs(Math.sin(phase)) * 0.06 * this._breathConf;
- }
- sizes.needsUpdate = true;
- }
- }
-
- dispose() {
- this._breathRing.geometry.dispose();
- this._breathMat.dispose();
- this._hrRing.geometry.dispose();
- this._hrMat.dispose();
- this._orb.geometry.dispose();
- this._orbMat.dispose();
- this._trails.geometry.dispose();
- this._trails.material.dispose();
- }
-}