fix: brighten ambient light for room brightness slider to work visibly
Changed ambient light from dark 0x446688 to bright 0xccccdd with 5x multiplier. Bumped SETTINGS_VERSION to 6. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
90a9201afe
commit
5c5149dc38
|
|
@ -0,0 +1,13 @@
|
|||
{"type":"edit","file":"unknown","timestamp":1772725155061,"sessionId":null}
|
||||
{"type":"edit","file":"unknown","timestamp":1772725341920,"sessionId":null}
|
||||
{"type":"edit","file":"unknown","timestamp":1772725344759,"sessionId":null}
|
||||
{"type":"edit","file":"unknown","timestamp":1772725350123,"sessionId":null}
|
||||
{"type":"edit","file":"unknown","timestamp":1772725549376,"sessionId":null}
|
||||
{"type":"edit","file":"unknown","timestamp":1772725716975,"sessionId":null}
|
||||
{"type":"edit","file":"unknown","timestamp":1772725889463,"sessionId":null}
|
||||
{"type":"edit","file":"unknown","timestamp":1772725893374,"sessionId":null}
|
||||
{"type":"edit","file":"unknown","timestamp":1772726006058,"sessionId":null}
|
||||
{"type":"edit","file":"unknown","timestamp":1772726169252,"sessionId":null}
|
||||
{"type":"edit","file":"unknown","timestamp":1772726170029,"sessionId":null}
|
||||
{"type":"edit","file":"unknown","timestamp":1772726177792,"sessionId":null}
|
||||
{"type":"edit","file":"unknown","timestamp":1772726186165,"sessionId":null}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"id": "session-1772726138295",
|
||||
"startedAt": "2026-03-05T15:55:38.296Z",
|
||||
"cwd": "C:\\Users\\ruv\\Projects\\wifi-densepose",
|
||||
"context": {},
|
||||
"metrics": {
|
||||
"edits": 4,
|
||||
"commands": 0,
|
||||
"tasks": 0,
|
||||
"errors": 0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 79d412ea5fcf92f0efe658d52827a0e0a96ff442
|
||||
|
|
@ -0,0 +1,698 @@
|
|||
/* ============================================================
|
||||
RuView Observatory — Foundation Color Scheme
|
||||
Warm dark background, electric green wireframe, amber data
|
||||
============================================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=JetBrains+Mono:wght@400;600&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-deep: #080c14;
|
||||
--bg-panel: rgba(8, 16, 28, 0.85);
|
||||
--bg-panel-border: rgba(0, 210, 120, 0.2);
|
||||
--green-glow: #00d878;
|
||||
--green-bright:#3eff8a;
|
||||
--green-dim: #0a6b3a;
|
||||
--amber: #ffb020;
|
||||
--amber-dim: #a06800;
|
||||
--blue-signal: #2090ff;
|
||||
--blue-dim: #0a3060;
|
||||
--red-alert: #ff3040;
|
||||
--red-heart: #ff4060;
|
||||
--text-primary: #e8ece0;
|
||||
--text-secondary: rgba(232,236,224, 0.55);
|
||||
--text-label: rgba(232,236,224, 0.4);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--bg-deep);
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', -apple-system, sans-serif;
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#observatory-canvas {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100vw; height: 100vh;
|
||||
}
|
||||
|
||||
/* ---- HUD Overlay ---- */
|
||||
#hud {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* ---- Brand ---- */
|
||||
#brand {
|
||||
position: absolute;
|
||||
top: 24px; left: 28px;
|
||||
}
|
||||
|
||||
#brand-logo {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 32px;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.5px;
|
||||
text-shadow: 0 0 30px rgba(0, 216, 120, 0.3);
|
||||
}
|
||||
|
||||
.pi {
|
||||
color: var(--green-glow);
|
||||
font-style: italic;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
#brand-tagline {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---- Status bar (top right) ---- */
|
||||
#status-bar {
|
||||
position: absolute;
|
||||
top: 24px; right: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
#data-source-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(0, 216, 120, 0.1);
|
||||
border: 1px solid rgba(0, 216, 120, 0.25);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.dot--demo { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
|
||||
.dot--live { background: var(--green-glow); box-shadow: 0 0 6px var(--green-glow); animation: pulse-dot 2s infinite; }
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
#scenario-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 14px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 176, 32, 0.1);
|
||||
border: 1px solid rgba(255, 176, 32, 0.25);
|
||||
pointer-events: auto;
|
||||
}
|
||||
#autoplay-icon {
|
||||
font-size: 10px;
|
||||
color: var(--green-glow);
|
||||
animation: pulse-dot 2s infinite;
|
||||
}
|
||||
#autoplay-icon.hidden { display: none; }
|
||||
#scenario-quick-select {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--amber);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
#scenario-quick-select:hover,
|
||||
#scenario-quick-select:focus { color: var(--green-glow); }
|
||||
#scenario-quick-select option {
|
||||
background: #0c1420;
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
#fps-counter {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ---- Data Panels ---- */
|
||||
.data-panel {
|
||||
position: absolute;
|
||||
width: 220px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--bg-panel-border);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-label);
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
/* ---- Vitals Panel (left) ---- */
|
||||
#panel-vitals {
|
||||
left: 28px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.vital-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.vital-row:last-child { margin-bottom: 0; }
|
||||
|
||||
.vital-icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
margin-top: 2px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vital-row:nth-child(2) .vital-icon { color: var(--red-heart); }
|
||||
.vital-row:nth-child(3) .vital-icon { color: var(--green-glow); }
|
||||
.vital-row:nth-child(4) .vital-icon { color: var(--amber); }
|
||||
|
||||
.vital-data { flex: 1; }
|
||||
|
||||
.vital-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-label);
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.vital-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.vital-unit {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.vital-bar {
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: 2px;
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vital-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
.vital-bar--hr { background: var(--red-heart); width: 0%; }
|
||||
.vital-bar--br { background: var(--green-glow); width: 0%; }
|
||||
.vital-bar--conf { background: var(--amber); width: 0%; }
|
||||
|
||||
/* ---- Signal Panel (right) ---- */
|
||||
#panel-signal {
|
||||
right: 28px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.signal-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.signal-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-label);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.signal-value {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--blue-signal);
|
||||
}
|
||||
|
||||
#rssi-sparkline {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
margin-top: 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* Presence */
|
||||
.presence-state {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.presence--absent {
|
||||
background: rgba(255,255,255,0.03);
|
||||
color: var(--text-label);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.presence--present {
|
||||
background: rgba(0, 216, 120, 0.1);
|
||||
color: var(--green-glow);
|
||||
border: 1px solid rgba(0, 216, 120, 0.3);
|
||||
box-shadow: 0 0 20px rgba(0, 216, 120, 0.1);
|
||||
}
|
||||
|
||||
.presence--active {
|
||||
background: rgba(255, 176, 32, 0.1);
|
||||
color: var(--amber);
|
||||
border: 1px solid rgba(255, 176, 32, 0.3);
|
||||
box-shadow: 0 0 20px rgba(255, 176, 32, 0.1);
|
||||
}
|
||||
|
||||
.fall-alert {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
background: rgba(255, 48, 64, 0.15);
|
||||
color: var(--red-alert);
|
||||
border: 1px solid rgba(255, 48, 64, 0.4);
|
||||
animation: pulse-alert 0.8s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-alert {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ---- Capabilities Bar (bottom center) ---- */
|
||||
#capabilities-bar {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--bg-panel-border);
|
||||
border-radius: 30px;
|
||||
padding: 8px 24px;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.cap-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.cap-icon {
|
||||
font-size: 16px;
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
.cap-item:nth-child(3) .cap-icon { color: var(--red-heart); }
|
||||
.cap-item:nth-child(5) .cap-icon { color: var(--blue-signal); }
|
||||
|
||||
.cap-divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
/* ---- Key hints ---- */
|
||||
#key-hints {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 28px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.key-hint {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: rgba(255,255,255,0.2);
|
||||
letter-spacing: 0.5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
/* ---- Settings button ---- */
|
||||
#settings-btn {
|
||||
pointer-events: auto;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: var(--text-secondary);
|
||||
font-size: 18px;
|
||||
width: 34px; height: 34px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
#settings-btn:hover {
|
||||
background: rgba(0, 216, 120, 0.15);
|
||||
border-color: var(--green-glow);
|
||||
color: var(--green-glow);
|
||||
}
|
||||
|
||||
/* ---- Settings Dialog ---- */
|
||||
.settings-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
z-index: 100;
|
||||
background: rgba(0,0,0,0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.settings-dialog {
|
||||
background: rgba(10, 16, 28, 0.96);
|
||||
border: 1px solid rgba(0, 216, 120, 0.2);
|
||||
border-radius: 16px;
|
||||
width: 440px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.6), 0 0 40px rgba(0,216,120,0.05);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.settings-header button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.settings-header button:hover { color: var(--red-alert); }
|
||||
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.stab {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-label);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.stab:hover { color: var(--text-secondary); }
|
||||
.stab.active {
|
||||
color: var(--green-glow);
|
||||
border-bottom-color: var(--green-glow);
|
||||
}
|
||||
|
||||
.stab-content {
|
||||
display: none;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.stab-content.active { display: block; }
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.setting-row span:first-child {
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.setting-row input[type="range"] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
.setting-row input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--green-glow);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 6px rgba(0,216,120,0.4);
|
||||
}
|
||||
|
||||
.setting-row input[type="color"] {
|
||||
-webkit-appearance: none;
|
||||
width: 36px; height: 24px;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.setting-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 2px; }
|
||||
.setting-row input[type="color"]::-webkit-color-swatch { border-radius: 2px; border: none; }
|
||||
|
||||
.setting-row select,
|
||||
.setting-row input[type="text"] {
|
||||
flex: 1;
|
||||
background: #0c1420;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
color: var(--text-primary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
}
|
||||
.setting-row select:focus,
|
||||
.setting-row input[type="text"]:focus {
|
||||
border-color: var(--green-glow);
|
||||
}
|
||||
.setting-row select option {
|
||||
background: #0c1420;
|
||||
color: var(--text-primary);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.setting-row select optgroup {
|
||||
background: #0a1018;
|
||||
color: var(--green-glow);
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.setting-row input[type="checkbox"] {
|
||||
width: 18px; height: 18px;
|
||||
accent-color: var(--green-glow);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.check-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.range-val {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--green-glow);
|
||||
min-width: 44px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-top: 6px;
|
||||
background: rgba(0, 216, 120, 0.08);
|
||||
border: 1px solid rgba(0, 216, 120, 0.2);
|
||||
color: var(--green-glow);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.settings-btn:hover {
|
||||
background: rgba(0, 216, 120, 0.15);
|
||||
border-color: var(--green-glow);
|
||||
}
|
||||
|
||||
/* ---- Scenario Description ---- */
|
||||
#scenario-description {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: 28px;
|
||||
max-width: 340px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
letter-spacing: 0.3px;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
/* ---- Edge Module Badges ---- */
|
||||
#edge-modules-bar {
|
||||
position: absolute;
|
||||
bottom: 58px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.edge-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
color: var(--badge-color, var(--text-secondary));
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid var(--badge-color, rgba(255,255,255,0.1));
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--badge-color, transparent) 30%, transparent);
|
||||
}
|
||||
|
||||
/* ---- Person Count Dots ---- */
|
||||
.persons-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.person-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease;
|
||||
}
|
||||
|
||||
.person-dot--active {
|
||||
background: var(--green-glow);
|
||||
border-color: var(--green-glow);
|
||||
box-shadow: 0 0 4px rgba(0, 216, 120, 0.4);
|
||||
}
|
||||
|
||||
/* ---- Vital Value Color Transitions ---- */
|
||||
.vital-value span:first-child {
|
||||
transition: color 0.6s ease;
|
||||
}
|
||||
|
||||
.vital-bar-fill {
|
||||
transition: width 0.5s ease, background 0.6s ease;
|
||||
}
|
||||
|
||||
/* ---- Responsive ---- */
|
||||
@media (max-width: 1200px) {
|
||||
.data-panel { width: 190px; padding: 12px; }
|
||||
.vital-value { font-size: 22px; }
|
||||
#capabilities-bar { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.data-panel { display: none; }
|
||||
#key-hints { display: none; }
|
||||
.settings-dialog { width: 95vw; }
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
/**
|
||||
* Module E — "Statistical Convergence Engine"
|
||||
* RSSI waveform, person orbs, classification, fall alert, metric bars
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const WAVEFORM_POINTS = 120;
|
||||
|
||||
export class ConvergenceEngine {
|
||||
constructor(scene, panelGroup) {
|
||||
this.group = new THREE.Group();
|
||||
if (panelGroup) panelGroup.add(this.group);
|
||||
else scene.add(this.group);
|
||||
|
||||
// --- RSSI Waveform (scrolling line) ---
|
||||
this._rssiHistory = new Float32Array(WAVEFORM_POINTS);
|
||||
const waveGeo = new THREE.BufferGeometry();
|
||||
this._wavePositions = new Float32Array(WAVEFORM_POINTS * 3);
|
||||
for (let i = 0; i < WAVEFORM_POINTS; i++) {
|
||||
this._wavePositions[i * 3] = (i / WAVEFORM_POINTS) * 6 - 3; // x: -3 to 3
|
||||
this._wavePositions[i * 3 + 1] = 0;
|
||||
this._wavePositions[i * 3 + 2] = 0;
|
||||
}
|
||||
waveGeo.setAttribute('position', new THREE.BufferAttribute(this._wavePositions, 3));
|
||||
const waveMat = new THREE.LineBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this._waveform = new THREE.Line(waveGeo, waveMat);
|
||||
this._waveform.position.y = 1.5;
|
||||
this.group.add(this._waveform);
|
||||
|
||||
// Waveform glow (thicker, dimmer duplicate)
|
||||
const glowMat = new THREE.LineBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.2,
|
||||
linewidth: 2,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this._waveGlow = new THREE.Line(waveGeo.clone(), glowMat);
|
||||
this._waveGlow.position.y = 1.5;
|
||||
this._waveGlow.scale.set(1, 1.3, 1);
|
||||
this.group.add(this._waveGlow);
|
||||
|
||||
// --- Person orbs (up to 4) ---
|
||||
this._personOrbs = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const orbGeo = new THREE.SphereGeometry(0.2, 16, 16);
|
||||
const orbMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff8800,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const orb = new THREE.Mesh(orbGeo, orbMat);
|
||||
orb.position.set(-2 + i * 1.2, -0.5, 0);
|
||||
this.group.add(orb);
|
||||
|
||||
const light = new THREE.PointLight(0xff8800, 0, 3);
|
||||
orb.add(light);
|
||||
|
||||
this._personOrbs.push({ mesh: orb, light, mat: orbMat });
|
||||
}
|
||||
|
||||
// --- Classification text sprite ---
|
||||
this._classCanvas = document.createElement('canvas');
|
||||
this._classCanvas.width = 256;
|
||||
this._classCanvas.height = 48;
|
||||
this._classCtx = this._classCanvas.getContext('2d');
|
||||
this._classTex = new THREE.CanvasTexture(this._classCanvas);
|
||||
const classMat = new THREE.SpriteMaterial({
|
||||
map: this._classTex,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
this._classSprite = new THREE.Sprite(classMat);
|
||||
this._classSprite.scale.set(3, 0.6, 1);
|
||||
this._classSprite.position.y = 0.3;
|
||||
this.group.add(this._classSprite);
|
||||
|
||||
// --- Fall alert ring ---
|
||||
const alertGeo = new THREE.TorusGeometry(2.5, 0.05, 8, 48);
|
||||
this._alertMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff2244,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
this._alertRing = new THREE.Mesh(alertGeo, this._alertMat);
|
||||
this._alertRing.rotation.x = Math.PI / 2;
|
||||
this._alertRing.position.y = -1;
|
||||
this.group.add(this._alertRing);
|
||||
|
||||
// --- Metric bars (3: frame rate, confidence, variance) ---
|
||||
this._metricBars = [];
|
||||
const barLabels = ['CONF', 'VAR', 'SPEC'];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const barGeo = new THREE.PlaneGeometry(0.15, 1.5);
|
||||
const barMat = new THREE.MeshBasicMaterial({
|
||||
color: [0x00d4ff, 0x8844ff, 0xff8800][i],
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
const bar = new THREE.Mesh(barGeo, barMat);
|
||||
bar.position.set(2 + i * 0.4, -1.2, 0);
|
||||
this.group.add(bar);
|
||||
this._metricBars.push({ mesh: bar, mat: barMat });
|
||||
}
|
||||
|
||||
this._rssiHead = 0;
|
||||
this._lastClassification = '';
|
||||
}
|
||||
|
||||
update(dt, elapsed, data) {
|
||||
const features = data?.features || {};
|
||||
const classification = data?.classification || {};
|
||||
const persons = data?.persons || [];
|
||||
const estPersons = data?.estimated_persons || 0;
|
||||
|
||||
// --- Update RSSI waveform ---
|
||||
const rssi = features.mean_rssi || -50;
|
||||
this._rssiHistory[this._rssiHead] = rssi;
|
||||
this._rssiHead = (this._rssiHead + 1) % WAVEFORM_POINTS;
|
||||
|
||||
for (let i = 0; i < WAVEFORM_POINTS; i++) {
|
||||
const histIdx = (this._rssiHead + i) % WAVEFORM_POINTS;
|
||||
const val = this._rssiHistory[histIdx];
|
||||
// Normalize RSSI (-80 to -20 range) to -1.5 to 1.5
|
||||
this._wavePositions[i * 3 + 1] = ((val + 50) / 30) * 1.5;
|
||||
}
|
||||
this._waveform.geometry.attributes.position.needsUpdate = true;
|
||||
|
||||
// Copy to glow
|
||||
const glowPos = this._waveGlow.geometry.attributes.position;
|
||||
glowPos.array.set(this._wavePositions);
|
||||
glowPos.needsUpdate = true;
|
||||
|
||||
// --- Person orbs ---
|
||||
for (let i = 0; i < this._personOrbs.length; i++) {
|
||||
const { mesh, light, mat } = this._personOrbs[i];
|
||||
if (i < estPersons) {
|
||||
mat.opacity = 0.7;
|
||||
light.intensity = 1.0 + Math.sin(elapsed * 3 + i * 1.5) * 0.5;
|
||||
const pulse = 1.0 + Math.sin(elapsed * 2 + i) * 0.15;
|
||||
mesh.scale.set(pulse, pulse, pulse);
|
||||
} else {
|
||||
mat.opacity = 0.05;
|
||||
light.intensity = 0;
|
||||
mesh.scale.set(0.5, 0.5, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Classification text ---
|
||||
const motionLevel = classification.motion_level || 'absent';
|
||||
const label = motionLevel.toUpperCase().replace('_', ' ');
|
||||
if (label !== this._lastClassification) {
|
||||
this._lastClassification = label;
|
||||
const ctx = this._classCtx;
|
||||
ctx.clearRect(0, 0, 256, 48);
|
||||
ctx.font = '600 24px "Courier New", monospace';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
if (motionLevel === 'active') ctx.fillStyle = '#ff8800';
|
||||
else if (motionLevel.includes('present')) ctx.fillStyle = '#00d4ff';
|
||||
else ctx.fillStyle = '#445566';
|
||||
|
||||
ctx.fillText(label, 128, 32);
|
||||
this._classTex.needsUpdate = true;
|
||||
}
|
||||
|
||||
// --- Fall alert ---
|
||||
const fallDetected = classification.fall_detected || false;
|
||||
if (fallDetected) {
|
||||
this._alertMat.opacity = 0.3 + Math.abs(Math.sin(elapsed * 6)) * 0.5;
|
||||
const scale = 1.0 + Math.sin(elapsed * 4) * 0.1;
|
||||
this._alertRing.scale.set(scale, scale, 1);
|
||||
} else {
|
||||
this._alertMat.opacity = 0;
|
||||
}
|
||||
|
||||
// --- Metric bars ---
|
||||
const confidence = classification.confidence || 0;
|
||||
const variance = Math.min(1, (features.variance || 0) / 5);
|
||||
const spectral = Math.min(1, (features.spectral_power || 0) / 0.5);
|
||||
const values = [confidence, variance, spectral];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const bar = this._metricBars[i];
|
||||
const v = values[i];
|
||||
bar.mesh.scale.y = Math.max(0.05, v);
|
||||
bar.mesh.position.y = -1.2 + v * 0.75;
|
||||
bar.mat.opacity = 0.3 + v * 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._waveform.geometry.dispose();
|
||||
this._waveform.material.dispose();
|
||||
this._waveGlow.geometry.dispose();
|
||||
this._waveGlow.material.dispose();
|
||||
this._alertRing.geometry.dispose();
|
||||
this._alertMat.dispose();
|
||||
this._classTex.dispose();
|
||||
for (const { mesh, mat } of this._personOrbs) {
|
||||
mesh.geometry.dispose();
|
||||
mat.dispose();
|
||||
}
|
||||
for (const { mesh, mat } of this._metricBars) {
|
||||
mesh.geometry.dispose();
|
||||
mat.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Holographic Panel — Reusable frame with border shader, scan line, title
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const BORDER_VERTEX = `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const BORDER_FRAGMENT = `
|
||||
uniform float uTime;
|
||||
uniform vec3 uColor;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
// Thin border
|
||||
float bx = step(vUv.x, 0.015) + step(1.0 - 0.015, vUv.x);
|
||||
float by = step(vUv.y, 0.02) + step(1.0 - 0.02, vUv.y);
|
||||
float border = clamp(bx + by, 0.0, 1.0);
|
||||
|
||||
// Scan line moving upward
|
||||
float scan = smoothstep(0.0, 0.02, abs(vUv.y - fract(uTime * 0.15))) ;
|
||||
scan = 1.0 - (1.0 - scan) * 0.4;
|
||||
|
||||
// Corner accents
|
||||
float corner = 0.0;
|
||||
float cx = min(vUv.x, 1.0 - vUv.x);
|
||||
float cy = min(vUv.y, 1.0 - vUv.y);
|
||||
if (cx < 0.06 && cy < 0.08) corner = 0.6;
|
||||
|
||||
// Subtle fill
|
||||
float fill = 0.03 + corner * 0.05;
|
||||
|
||||
float alpha = max(border * 0.7, fill) * scan;
|
||||
gl_FragColor = vec4(uColor, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
export class HolographicPanel {
|
||||
/**
|
||||
* @param {Object} opts
|
||||
* @param {number[]} opts.position - [x, y, z]
|
||||
* @param {number} opts.width
|
||||
* @param {number} opts.height
|
||||
* @param {string} opts.title
|
||||
* @param {number} [opts.color=0x00d4ff]
|
||||
*/
|
||||
constructor(opts) {
|
||||
this.group = new THREE.Group();
|
||||
this.group.position.set(...opts.position);
|
||||
|
||||
const color = new THREE.Color(opts.color || 0x00d4ff);
|
||||
|
||||
// Border plane
|
||||
this._uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uColor: { value: color },
|
||||
};
|
||||
|
||||
const borderGeo = new THREE.PlaneGeometry(opts.width, opts.height);
|
||||
const borderMat = new THREE.ShaderMaterial({
|
||||
vertexShader: BORDER_VERTEX,
|
||||
fragmentShader: BORDER_FRAGMENT,
|
||||
uniforms: this._uniforms,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this._border = new THREE.Mesh(borderGeo, borderMat);
|
||||
this.group.add(this._border);
|
||||
|
||||
// Title sprite
|
||||
if (opts.title) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 512;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = 'transparent';
|
||||
ctx.fillRect(0, 0, 512, 64);
|
||||
ctx.font = '600 28px "Courier New", monospace';
|
||||
ctx.fillStyle = `#${color.getHexString()}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(opts.title.toUpperCase(), 256, 42);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const spriteMat = new THREE.SpriteMaterial({
|
||||
map: tex,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sprite = new THREE.Sprite(spriteMat);
|
||||
sprite.scale.set(opts.width * 0.8, opts.width * 0.1, 1);
|
||||
sprite.position.y = opts.height / 2 + 0.3;
|
||||
this.group.add(sprite);
|
||||
this._titleSprite = sprite;
|
||||
this._titleTex = tex;
|
||||
}
|
||||
}
|
||||
|
||||
update(dt, elapsed) {
|
||||
this._uniforms.uTime.value = elapsed;
|
||||
}
|
||||
|
||||
/** Make panel face camera */
|
||||
lookAt(cameraPos) {
|
||||
this.group.lookAt(cameraPos);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._border.geometry.dispose();
|
||||
this._border.material.dispose();
|
||||
if (this._titleTex) this._titleTex.dispose();
|
||||
if (this._titleSprite) this._titleSprite.material.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ export const DEFAULTS = {
|
|||
scenario: 'auto', cycle: 30, dataSource: 'demo', wsUrl: '',
|
||||
};
|
||||
|
||||
export const SETTINGS_VERSION = '5';
|
||||
export const SETTINGS_VERSION = '6';
|
||||
|
||||
export const PRESETS = {
|
||||
foundation: {},
|
||||
|
|
@ -196,7 +196,7 @@ export class HudController {
|
|||
this._bindRange('opt-aura', 'aura');
|
||||
this._bindRange('opt-field', 'field', v => { obs._fieldMat.opacity = v; });
|
||||
this._bindRange('opt-waves', 'waves');
|
||||
this._bindRange('opt-ambient', 'ambient', v => { obs._ambient.intensity = v * 3.0; });
|
||||
this._bindRange('opt-ambient', 'ambient', v => { obs._ambient.intensity = v * 5.0; });
|
||||
this._bindRange('opt-reflect', 'reflect', v => {
|
||||
obs._floorMat.roughness = 1.0 - v * 0.7;
|
||||
obs._floorMat.metalness = v * 0.5;
|
||||
|
|
@ -346,7 +346,7 @@ export class HudController {
|
|||
obs._applyPostSettings();
|
||||
obs._renderer.toneMappingExposure = obs.settings.exposure;
|
||||
obs._fieldMat.opacity = obs.settings.field;
|
||||
obs._ambient.intensity = obs.settings.ambient * 3.0;
|
||||
obs._ambient.intensity = obs.settings.ambient * 5.0;
|
||||
obs._floorMat.roughness = 1.0 - obs.settings.reflect * 0.7;
|
||||
obs._floorMat.metalness = obs.settings.reflect * 0.5;
|
||||
obs._camera.fov = obs.settings.fov;
|
||||
|
|
@ -0,0 +1,715 @@
|
|||
/**
|
||||
* RuView Observatory — Main Scene Orchestrator
|
||||
*
|
||||
* Room-based WiFi sensing visualization with:
|
||||
* - Pool of 4 human wireframe figures (multi-person scenarios)
|
||||
* - 7 pose types (standing, walking, lying, sitting, fallen, exercising, gesturing, crouching)
|
||||
* - Scenario-specific room props (chair, exercise mat, door, rubble wall, screen, desk)
|
||||
* - Dot-matrix mist body mass, particle trails, WiFi waves, signal field
|
||||
* - Reflective floor, settings dialog, and practical data HUD
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
import { DemoDataGenerator } from './demo-data.js';
|
||||
import { NebulaBackground } from './nebula-background.js';
|
||||
import { PostProcessing } from './post-processing.js';
|
||||
import { FigurePool, SKELETON_PAIRS } from './figure-pool.js';
|
||||
import { PoseSystem } from './pose-system.js';
|
||||
import { ScenarioProps } from './scenario-props.js';
|
||||
import { HudController, DEFAULTS, SETTINGS_VERSION, PRESETS, SCENARIO_NAMES } from './hud-controller.js';
|
||||
|
||||
// ---- Palette ----
|
||||
const C = {
|
||||
greenGlow: 0x00d878,
|
||||
greenBright:0x3eff8a,
|
||||
greenDim: 0x0a6b3a,
|
||||
amber: 0xffb020,
|
||||
blueSignal: 0x2090ff,
|
||||
redAlert: 0xff3040,
|
||||
redHeart: 0xff4060,
|
||||
bgDeep: 0x080c14,
|
||||
};
|
||||
|
||||
// SCENARIO_NAMES, DEFAULTS, SETTINGS_VERSION, PRESETS imported from hud-controller.js
|
||||
|
||||
// ---- Main Class ----
|
||||
|
||||
class Observatory {
|
||||
constructor() {
|
||||
this._canvas = document.getElementById('observatory-canvas');
|
||||
this.settings = { ...DEFAULTS };
|
||||
|
||||
// Load saved settings
|
||||
try {
|
||||
const ver = localStorage.getItem('ruview-settings-version');
|
||||
if (ver === SETTINGS_VERSION) {
|
||||
const saved = localStorage.getItem('ruview-observatory-settings');
|
||||
if (saved) Object.assign(this.settings, JSON.parse(saved));
|
||||
} else {
|
||||
localStorage.removeItem('ruview-observatory-settings');
|
||||
localStorage.setItem('ruview-settings-version', SETTINGS_VERSION);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Renderer
|
||||
this._renderer = new THREE.WebGLRenderer({
|
||||
canvas: this._canvas,
|
||||
antialias: true,
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this._renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
this._renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
this._renderer.toneMappingExposure = this.settings.exposure;
|
||||
this._renderer.shadowMap.enabled = true;
|
||||
this._renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
|
||||
// Scene
|
||||
this._scene = new THREE.Scene();
|
||||
this._scene.background = new THREE.Color(C.bgDeep);
|
||||
this._scene.fog = new THREE.FogExp2(C.bgDeep, 0.005);
|
||||
|
||||
// Camera
|
||||
this._camera = new THREE.PerspectiveCamera(
|
||||
this.settings.fov, window.innerWidth / window.innerHeight, 0.1, 300
|
||||
);
|
||||
this._camera.position.set(6, 5, 8);
|
||||
this._camera.lookAt(0, 1.2, 0);
|
||||
|
||||
// Controls
|
||||
this._controls = new OrbitControls(this._camera, this._canvas);
|
||||
this._controls.enableDamping = true;
|
||||
this._controls.dampingFactor = 0.08;
|
||||
this._controls.minDistance = 2;
|
||||
this._controls.maxDistance = 25;
|
||||
this._controls.maxPolarAngle = Math.PI * 0.88;
|
||||
this._controls.target.set(0, 1.2, 0);
|
||||
this._controls.update();
|
||||
|
||||
this._clock = new THREE.Clock();
|
||||
|
||||
// Data
|
||||
this._demoData = new DemoDataGenerator();
|
||||
this._demoData.setCycleDuration(this.settings.cycle || 30);
|
||||
if (this.settings.scenario && this.settings.scenario !== 'auto') {
|
||||
this._demoData.setScenario(this.settings.scenario);
|
||||
}
|
||||
this._currentData = null;
|
||||
this._currentScenario = null;
|
||||
|
||||
// Build scene
|
||||
this._setupLighting();
|
||||
this._nebula = new NebulaBackground(this._scene);
|
||||
this._buildRoom();
|
||||
this._buildRouter();
|
||||
this._poseSystem = new PoseSystem();
|
||||
this._figurePool = new FigurePool(this._scene, this.settings, this._poseSystem);
|
||||
this._scenarioProps = new ScenarioProps(this._scene);
|
||||
this._buildDotMatrixMist();
|
||||
this._buildParticleTrail();
|
||||
this._buildWifiWaves();
|
||||
this._buildSignalField();
|
||||
|
||||
// Post-processing
|
||||
this._postProcessing = new PostProcessing(this._renderer, this._scene, this._camera);
|
||||
this._applyPostSettings();
|
||||
|
||||
// HUD controller (settings dialog, sparkline, vital displays)
|
||||
this._hud = new HudController(this);
|
||||
|
||||
// State
|
||||
this._autopilot = false;
|
||||
this._autoAngle = 0;
|
||||
this._fpsFrames = 0;
|
||||
this._fpsTime = 0;
|
||||
this._fpsValue = 60;
|
||||
this._showFps = false;
|
||||
this._qualityLevel = 2;
|
||||
|
||||
// WebSocket for live data — always try auto-detect on startup
|
||||
this._ws = null;
|
||||
this._liveData = null;
|
||||
this._autoDetectLive();
|
||||
|
||||
// Input
|
||||
this._initKeyboard();
|
||||
this._hud.initSettings();
|
||||
this._hud.initQuickSelect();
|
||||
window.addEventListener('resize', () => this._onResize());
|
||||
|
||||
// Start
|
||||
this._animate();
|
||||
}
|
||||
|
||||
// ---- Lighting ----
|
||||
|
||||
_setupLighting() {
|
||||
this._ambient = new THREE.AmbientLight(0xccccdd, this.settings.ambient * 5.0);
|
||||
this._scene.add(this._ambient);
|
||||
|
||||
const hemi = new THREE.HemisphereLight(0x6688bb, 0x203040, 1.2);
|
||||
this._scene.add(hemi);
|
||||
|
||||
const key = new THREE.DirectionalLight(0xffeedd, 1.2);
|
||||
key.position.set(4, 8, 3);
|
||||
key.castShadow = true;
|
||||
key.shadow.mapSize.set(1024, 1024);
|
||||
key.shadow.camera.near = 0.5;
|
||||
key.shadow.camera.far = 20;
|
||||
key.shadow.camera.left = -8;
|
||||
key.shadow.camera.right = 8;
|
||||
key.shadow.camera.top = 8;
|
||||
key.shadow.camera.bottom = -8;
|
||||
this._scene.add(key);
|
||||
|
||||
// Fill light from opposite side
|
||||
const fill = new THREE.DirectionalLight(0x8899bb, 0.7);
|
||||
fill.position.set(-4, 5, -2);
|
||||
this._scene.add(fill);
|
||||
|
||||
// Rim light from above/behind for edge definition
|
||||
const rim = new THREE.DirectionalLight(0x6699cc, 0.5);
|
||||
rim.position.set(0, 6, -5);
|
||||
this._scene.add(rim);
|
||||
|
||||
// Overhead room light — general illumination
|
||||
const overhead = new THREE.PointLight(0x8899aa, 1.0, 20, 1.0);
|
||||
overhead.position.set(0, 3.8, 0);
|
||||
this._scene.add(overhead);
|
||||
}
|
||||
|
||||
// ---- Room ----
|
||||
|
||||
_buildRoom() {
|
||||
this._grid = new THREE.GridHelper(12, 24, 0x1a4830, 0x0c2818);
|
||||
this._grid.material.opacity = 0.5;
|
||||
this._grid.material.transparent = true;
|
||||
this._scene.add(this._grid);
|
||||
|
||||
const boxGeo = new THREE.BoxGeometry(12, 4, 10);
|
||||
const edges = new THREE.EdgesGeometry(boxGeo);
|
||||
this._roomWire = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({
|
||||
color: C.greenDim, opacity: 0.3, transparent: true,
|
||||
}));
|
||||
this._roomWire.position.y = 2;
|
||||
this._scene.add(this._roomWire);
|
||||
|
||||
// Reflective floor
|
||||
const floorGeo = new THREE.PlaneGeometry(12, 10);
|
||||
this._floorMat = new THREE.MeshStandardMaterial({
|
||||
color: 0x101810,
|
||||
roughness: 1.0 - this.settings.reflect * 0.7,
|
||||
metalness: this.settings.reflect * 0.5,
|
||||
emissive: 0x020404,
|
||||
emissiveIntensity: 0.08,
|
||||
});
|
||||
const floor = new THREE.Mesh(floorGeo, this._floorMat);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.receiveShadow = true;
|
||||
this._scene.add(floor);
|
||||
|
||||
// Table under router
|
||||
const tableGeo = new THREE.BoxGeometry(0.8, 0.6, 0.5);
|
||||
const tableMat = new THREE.MeshStandardMaterial({ color: 0x6b5840, roughness: 0.55, emissive: 0x1a1408, emissiveIntensity: 0.25 });
|
||||
const table = new THREE.Mesh(tableGeo, tableMat);
|
||||
table.position.set(-4, 0.3, -3);
|
||||
table.castShadow = true;
|
||||
this._scene.add(table);
|
||||
}
|
||||
|
||||
// ---- Router ----
|
||||
|
||||
_buildRouter() {
|
||||
this._routerGroup = new THREE.Group();
|
||||
this._routerGroup.position.set(-4, 0.92, -3);
|
||||
|
||||
const bodyGeo = new THREE.BoxGeometry(0.6, 0.12, 0.35);
|
||||
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x505060, roughness: 0.2, metalness: 0.7, emissive: 0x101018, emissiveIntensity: 0.2 });
|
||||
this._routerGroup.add(new THREE.Mesh(bodyGeo, bodyMat));
|
||||
|
||||
for (let i = -1; i <= 1; i++) {
|
||||
const antGeo = new THREE.CylinderGeometry(0.015, 0.015, 0.35);
|
||||
const antMat = new THREE.MeshStandardMaterial({ color: 0x606068, roughness: 0.3, metalness: 0.6, emissive: 0x101018, emissiveIntensity: 0.15 });
|
||||
const ant = new THREE.Mesh(antGeo, antMat);
|
||||
ant.position.set(i * 0.2, 0.24, 0);
|
||||
ant.rotation.z = i * 0.15;
|
||||
this._routerGroup.add(ant);
|
||||
}
|
||||
|
||||
const ledGeo = new THREE.SphereGeometry(0.025);
|
||||
this._routerLed = new THREE.Mesh(ledGeo, new THREE.MeshBasicMaterial({ color: C.greenGlow }));
|
||||
this._routerLed.position.set(0.22, 0.07, 0.18);
|
||||
this._routerGroup.add(this._routerLed);
|
||||
|
||||
this._routerLight = new THREE.PointLight(C.blueSignal, 1.2, 8);
|
||||
this._routerLight.position.set(0, 0.3, 0);
|
||||
this._routerGroup.add(this._routerLight);
|
||||
|
||||
this._scene.add(this._routerGroup);
|
||||
}
|
||||
|
||||
// ---- WiFi Waves ----
|
||||
|
||||
_buildWifiWaves() {
|
||||
this._wifiWaves = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const radius = 0.8 + i * 1.0;
|
||||
const geo = new THREE.SphereGeometry(radius, 24, 16, 0, Math.PI * 2, 0, Math.PI * 0.6);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: C.blueSignal,
|
||||
transparent: true, opacity: 0,
|
||||
side: THREE.DoubleSide,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false, wireframe: true,
|
||||
});
|
||||
const shell = new THREE.Mesh(geo, mat);
|
||||
shell.position.copy(this._routerGroup.position);
|
||||
shell.position.y += 0.5;
|
||||
this._scene.add(shell);
|
||||
this._wifiWaves.push({ mesh: shell, mat, phase: i * 0.7 });
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// DOT MATRIX MIST
|
||||
// ========================================
|
||||
|
||||
_buildDotMatrixMist() {
|
||||
const COUNT = 800;
|
||||
const positions = new Float32Array(COUNT * 3);
|
||||
const alphas = new Float32Array(COUNT);
|
||||
for (let i = 0; i < COUNT; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const r = Math.random() * 0.5;
|
||||
positions[i * 3] = Math.cos(angle) * r;
|
||||
positions[i * 3 + 1] = Math.random() * 1.8;
|
||||
positions[i * 3 + 2] = Math.sin(angle) * r;
|
||||
alphas[i] = 0;
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('alpha', new THREE.BufferAttribute(alphas, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: `
|
||||
attribute float alpha;
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
vAlpha = alpha;
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_PointSize = 3.0 * (200.0 / -mv.z);
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
varying float vAlpha;
|
||||
void main() {
|
||||
float d = length(gl_PointCoord - 0.5);
|
||||
if (d > 0.5) discard;
|
||||
float edge = smoothstep(0.5, 0.2, d);
|
||||
gl_FragColor = vec4(uColor, edge * vAlpha);
|
||||
}
|
||||
`,
|
||||
uniforms: { uColor: { value: new THREE.Color(this.settings.wireColor) } },
|
||||
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||
});
|
||||
this._mistPoints = new THREE.Points(geo, mat);
|
||||
this._scene.add(this._mistPoints);
|
||||
this._mistCount = COUNT;
|
||||
}
|
||||
|
||||
// ---- Particle Trail ----
|
||||
|
||||
_buildParticleTrail() {
|
||||
const COUNT = 200;
|
||||
const positions = new Float32Array(COUNT * 3);
|
||||
const ages = new Float32Array(COUNT);
|
||||
for (let i = 0; i < COUNT; i++) ages[i] = 1;
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('age', new THREE.BufferAttribute(ages, 1));
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: `
|
||||
attribute float age;
|
||||
varying float vAge;
|
||||
void main() {
|
||||
vAge = age;
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_PointSize = max(1.0, (1.0 - age) * 5.0 * (150.0 / -mv.z));
|
||||
gl_Position = projectionMatrix * mv;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform vec3 uColor;
|
||||
varying float vAge;
|
||||
void main() {
|
||||
float d = length(gl_PointCoord - 0.5);
|
||||
if (d > 0.5) discard;
|
||||
float alpha = (1.0 - vAge) * 0.6 * smoothstep(0.5, 0.1, d);
|
||||
gl_FragColor = vec4(uColor, alpha);
|
||||
}
|
||||
`,
|
||||
uniforms: { uColor: { value: new THREE.Color(C.greenGlow) } },
|
||||
transparent: true, blending: THREE.AdditiveBlending, depthWrite: false,
|
||||
});
|
||||
this._trail = new THREE.Points(geo, mat);
|
||||
this._scene.add(this._trail);
|
||||
this._trailHead = 0;
|
||||
this._trailCount = COUNT;
|
||||
this._trailTimer = 0;
|
||||
}
|
||||
|
||||
// ---- Signal Field ----
|
||||
|
||||
_buildSignalField() {
|
||||
const gridSize = 20;
|
||||
const count = gridSize * gridSize;
|
||||
const positions = new Float32Array(count * 3);
|
||||
this._fieldColors = new Float32Array(count * 3);
|
||||
this._fieldSizes = new Float32Array(count);
|
||||
for (let iz = 0; iz < gridSize; iz++) {
|
||||
for (let ix = 0; ix < gridSize; ix++) {
|
||||
const idx = iz * gridSize + ix;
|
||||
positions[idx * 3] = (ix - gridSize / 2) * 0.6;
|
||||
positions[idx * 3 + 1] = 0.02;
|
||||
positions[idx * 3 + 2] = (iz - gridSize / 2) * 0.5;
|
||||
this._fieldSizes[idx] = 8;
|
||||
}
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(this._fieldColors, 3));
|
||||
geo.setAttribute('size', new THREE.BufferAttribute(this._fieldSizes, 1));
|
||||
this._fieldMat = new THREE.PointsMaterial({
|
||||
size: 0.35, vertexColors: true, transparent: true,
|
||||
opacity: this.settings.field, blending: THREE.AdditiveBlending,
|
||||
depthWrite: false, sizeAttenuation: true,
|
||||
});
|
||||
this._fieldPoints = new THREE.Points(geo, this._fieldMat);
|
||||
this._scene.add(this._fieldPoints);
|
||||
}
|
||||
|
||||
// ---- Keyboard ----
|
||||
|
||||
_initKeyboard() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (this._hud.settingsOpen) return;
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'a':
|
||||
this._autopilot = !this._autopilot;
|
||||
this._controls.enabled = !this._autopilot;
|
||||
break;
|
||||
case 'd': this._demoData.cycleScenario(); break;
|
||||
case 'f':
|
||||
this._showFps = !this._showFps;
|
||||
document.getElementById('fps-counter').style.display = this._showFps ? 'block' : 'none';
|
||||
break;
|
||||
case 's': this._hud.toggleSettings(); break;
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
this._demoData.paused = !this._demoData.paused;
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Settings / HUD methods delegated to HudController ----
|
||||
|
||||
_applyPostSettings() {
|
||||
const pp = this._postProcessing;
|
||||
pp._bloomPass.strength = this.settings.bloom;
|
||||
pp._bloomPass.radius = this.settings.bloomRadius;
|
||||
pp._bloomPass.threshold = this.settings.bloomThresh;
|
||||
pp._vignettePass.uniforms.uVignetteStrength.value = this.settings.vignette;
|
||||
pp._vignettePass.uniforms.uGrainStrength.value = this.settings.grain;
|
||||
pp._vignettePass.uniforms.uChromaticStrength.value = this.settings.chromatic;
|
||||
}
|
||||
|
||||
_applyColors() {
|
||||
const wc = new THREE.Color(this.settings.wireColor);
|
||||
const jc = new THREE.Color(this.settings.jointColor);
|
||||
this._figurePool.applyColors(wc, jc);
|
||||
this._mistPoints.material.uniforms.uColor.value.copy(wc);
|
||||
}
|
||||
|
||||
// ---- WebSocket live data ----
|
||||
|
||||
_autoDetectLive() {
|
||||
// Probe sensing server health on same origin, then common ports
|
||||
const host = window.location.hostname || 'localhost';
|
||||
const candidates = [
|
||||
window.location.origin, // same origin (e.g. :3000)
|
||||
`http://${host}:8765`, // default WS port
|
||||
`http://${host}:3000`, // default HTTP port
|
||||
];
|
||||
// Deduplicate
|
||||
const unique = [...new Set(candidates)];
|
||||
|
||||
const tryNext = (i) => {
|
||||
if (i >= unique.length) {
|
||||
console.log('[Observatory] No sensing server detected, using demo mode');
|
||||
return;
|
||||
}
|
||||
const base = unique[i];
|
||||
fetch(`${base}/health`, { signal: AbortSignal.timeout(1500) })
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(data => {
|
||||
if (data && data.status === 'ok') {
|
||||
const wsProto = base.startsWith('https') ? 'wss:' : 'ws:';
|
||||
const urlObj = new URL(base);
|
||||
const wsUrl = `${wsProto}//${urlObj.host}/ws/sensing`;
|
||||
console.log('[Observatory] Sensing server detected at', base, '→', wsUrl);
|
||||
this.settings.dataSource = 'ws';
|
||||
this.settings.wsUrl = wsUrl;
|
||||
this._connectWS(wsUrl);
|
||||
} else {
|
||||
tryNext(i + 1);
|
||||
}
|
||||
})
|
||||
.catch(() => tryNext(i + 1));
|
||||
};
|
||||
tryNext(0);
|
||||
}
|
||||
|
||||
_connectWS(url) {
|
||||
this._disconnectWS();
|
||||
try {
|
||||
this._ws = new WebSocket(url);
|
||||
this._ws.onopen = () => {
|
||||
console.log('[Observatory] WebSocket connected');
|
||||
this._hud.updateSourceBadge('ws', this._ws);
|
||||
};
|
||||
this._ws.onmessage = (evt) => { try { this._liveData = JSON.parse(evt.data); } catch {} };
|
||||
this._ws.onclose = () => {
|
||||
console.log('[Observatory] WebSocket closed, falling back to demo');
|
||||
this._ws = null;
|
||||
this.settings.dataSource = 'demo';
|
||||
this._hud.updateSourceBadge('demo', null);
|
||||
};
|
||||
this._ws.onerror = () => {};
|
||||
} catch {}
|
||||
}
|
||||
|
||||
_disconnectWS() {
|
||||
if (this._ws) { this._ws.close(); this._ws = null; }
|
||||
this._liveData = null;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ANIMATION LOOP
|
||||
// ========================================
|
||||
|
||||
_animate() {
|
||||
requestAnimationFrame(() => this._animate());
|
||||
const dt = Math.min(this._clock.getDelta(), 0.1);
|
||||
const elapsed = this._clock.getElapsedTime();
|
||||
|
||||
// Data source
|
||||
if (this.settings.dataSource === 'ws' && this._liveData) {
|
||||
this._currentData = this._liveData;
|
||||
} else {
|
||||
this._currentData = this._demoData.update(dt);
|
||||
}
|
||||
const data = this._currentData;
|
||||
|
||||
// Updates
|
||||
this._nebula.update(dt, elapsed);
|
||||
this._figurePool.update(data, elapsed);
|
||||
this._scenarioProps.update(data, this._demoData.currentScenario);
|
||||
this._updateDotMatrixMist(data, elapsed);
|
||||
this._updateParticleTrail(data, dt, elapsed);
|
||||
this._updateWifiWaves(elapsed);
|
||||
this._updateSignalField(data);
|
||||
this._hud.updateHUD(data, this._demoData);
|
||||
this._hud.updateSparkline(data);
|
||||
|
||||
// Router LED
|
||||
this._routerLed.material.opacity = 0.5 + 0.5 * Math.sin(elapsed * 8);
|
||||
this._routerLight.intensity = 0.3 + 0.2 * Math.sin(elapsed * 3);
|
||||
|
||||
// Autopilot orbit
|
||||
if (this._autopilot) {
|
||||
this._autoAngle += dt * this.settings.orbitSpeed;
|
||||
const r = 10;
|
||||
this._camera.position.set(
|
||||
Math.sin(this._autoAngle) * r,
|
||||
4.5 + Math.sin(this._autoAngle * 0.5),
|
||||
Math.cos(this._autoAngle) * r
|
||||
);
|
||||
this._controls.target.set(0, 1.2, 0);
|
||||
this._controls.update();
|
||||
}
|
||||
this._controls.update();
|
||||
this._postProcessing.update(elapsed);
|
||||
this._postProcessing.render();
|
||||
this._updateFPS(dt);
|
||||
}
|
||||
|
||||
|
||||
// ========================================
|
||||
// MIST & TRAIL
|
||||
// ========================================
|
||||
|
||||
_updateDotMatrixMist(data, elapsed) {
|
||||
const persons = data?.persons || [];
|
||||
const isPresent = data?.classification?.presence || false;
|
||||
const pos = this._mistPoints.geometry.attributes.position;
|
||||
const alpha = this._mistPoints.geometry.attributes.alpha;
|
||||
|
||||
if (!isPresent || persons.length === 0) {
|
||||
for (let i = 0; i < this._mistCount; i++) {
|
||||
alpha.array[i] = Math.max(0, alpha.array[i] - 0.02);
|
||||
}
|
||||
alpha.needsUpdate = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Follow primary person
|
||||
const pp = persons[0].position || [0, 0, 0];
|
||||
const px = pp[0] || 0, pz = pp[2] || 0;
|
||||
const ms = persons[0].motion_score || 0;
|
||||
const pose = persons[0].pose || 'standing';
|
||||
const isLying = pose === 'lying' || pose === 'fallen';
|
||||
const bodyH = isLying ? 0.4 : 1.7;
|
||||
const bodyBaseY = isLying ? (pp[1] || 0) + 0.05 : 0.05;
|
||||
const spread = ms > 50 ? 0.6 : 0.4;
|
||||
|
||||
for (let i = 0; i < this._mistCount; i++) {
|
||||
const drift = Math.sin(elapsed * 0.5 + i * 0.1) * 0.003;
|
||||
const angle = (i / this._mistCount) * Math.PI * 2 + elapsed * 0.1;
|
||||
const layerT = (i % 20) / 20;
|
||||
const layerY = bodyBaseY + layerT * bodyH;
|
||||
|
||||
let bodyWidth;
|
||||
if (isLying) {
|
||||
bodyWidth = 0.25;
|
||||
} else {
|
||||
bodyWidth = layerT > 0.75 ? 0.15 : (layerT > 0.45 ? 0.25 : 0.18);
|
||||
}
|
||||
const r = bodyWidth * (0.5 + 0.5 * Math.sin(i * 1.7 + elapsed * 0.3)) * spread;
|
||||
|
||||
const tx = px + Math.cos(angle + i * 0.3) * r + drift;
|
||||
const tz = pz + Math.sin(angle + i * 0.5) * r * 0.6;
|
||||
|
||||
pos.array[i * 3] += (tx - pos.array[i * 3]) * 0.05;
|
||||
pos.array[i * 3 + 1] += (layerY - pos.array[i * 3 + 1]) * 0.05;
|
||||
pos.array[i * 3 + 2] += (tz - pos.array[i * 3 + 2]) * 0.05;
|
||||
|
||||
const targetAlpha = 0.15 + Math.sin(elapsed * 2 + i * 0.5) * 0.08;
|
||||
alpha.array[i] += (targetAlpha - alpha.array[i]) * 0.08;
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
alpha.needsUpdate = true;
|
||||
}
|
||||
|
||||
_updateParticleTrail(data, dt, elapsed) {
|
||||
if (this.settings.trail <= 0) return;
|
||||
const persons = data?.persons || [];
|
||||
const isPresent = data?.classification?.presence || false;
|
||||
const pos = this._trail.geometry.attributes.position;
|
||||
const ages = this._trail.geometry.attributes.age;
|
||||
|
||||
for (let i = 0; i < this._trailCount; i++) {
|
||||
ages.array[i] = Math.min(1, ages.array[i] + dt * 0.8);
|
||||
}
|
||||
|
||||
// Emit from all active persons
|
||||
if (isPresent && persons.length > 0) {
|
||||
this._trailTimer += dt;
|
||||
const ms = persons[0].motion_score || 0;
|
||||
const emitRate = ms > 50 ? 0.02 : 0.08;
|
||||
|
||||
if (this._trailTimer >= emitRate) {
|
||||
this._trailTimer = 0;
|
||||
for (const p of persons) {
|
||||
const pp = p.position || [0, 0, 0];
|
||||
const idx = this._trailHead;
|
||||
pos.array[idx * 3] = (pp[0] || 0) + (Math.random() - 0.5) * 0.15;
|
||||
pos.array[idx * 3 + 1] = Math.random() * 1.5 + 0.1;
|
||||
pos.array[idx * 3 + 2] = (pp[2] || 0) + (Math.random() - 0.5) * 0.15;
|
||||
ages.array[idx] = 0;
|
||||
this._trailHead = (this._trailHead + 1) % this._trailCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
pos.needsUpdate = true;
|
||||
ages.needsUpdate = true;
|
||||
}
|
||||
|
||||
// ---- WiFi Waves ----
|
||||
|
||||
_updateWifiWaves(elapsed) {
|
||||
for (const w of this._wifiWaves) {
|
||||
const t = (elapsed * 0.8 + w.phase) % 4.5;
|
||||
const life = t / 4.5;
|
||||
w.mat.opacity = Math.max(0, this.settings.waves * 0.25 * (1 - life));
|
||||
const scale = 1 + life * 0.6;
|
||||
w.mesh.scale.set(scale, scale, scale);
|
||||
w.mesh.rotation.y = elapsed * 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Signal Field ----
|
||||
|
||||
_updateSignalField(data) {
|
||||
const field = data?.signal_field?.values;
|
||||
if (!field) return;
|
||||
const count = Math.min(field.length, 400);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const v = field[i] || 0;
|
||||
let r, g, b;
|
||||
if (v < 0.3) { r = 0; g = v * 1.5; b = v * 0.3; }
|
||||
else if (v < 0.6) {
|
||||
const t = (v - 0.3) / 0.3;
|
||||
r = t * 0.3; g = 0.45 + t * 0.4; b = 0.09 - t * 0.05;
|
||||
} else {
|
||||
const t = (v - 0.6) / 0.4;
|
||||
r = 0.3 + t * 0.7; g = 0.85 - t * 0.2; b = 0.04;
|
||||
}
|
||||
this._fieldColors[i * 3] = r;
|
||||
this._fieldColors[i * 3 + 1] = g;
|
||||
this._fieldColors[i * 3 + 2] = b;
|
||||
this._fieldSizes[i] = 5 + v * 15;
|
||||
}
|
||||
this._fieldPoints.geometry.attributes.color.needsUpdate = true;
|
||||
this._fieldPoints.geometry.attributes.size.needsUpdate = true;
|
||||
}
|
||||
|
||||
// ---- FPS ----
|
||||
|
||||
_updateFPS(dt) {
|
||||
this._fpsFrames++;
|
||||
this._fpsTime += dt;
|
||||
if (this._fpsTime >= 1) {
|
||||
this._fpsValue = Math.round(this._fpsFrames / this._fpsTime);
|
||||
this._fpsFrames = 0;
|
||||
this._fpsTime = 0;
|
||||
if (this._showFps) {
|
||||
document.getElementById('fps-counter').textContent = `${this._fpsValue} FPS`;
|
||||
}
|
||||
this._adaptQuality();
|
||||
}
|
||||
}
|
||||
|
||||
_adaptQuality() {
|
||||
let nl = this._qualityLevel;
|
||||
if (this._fpsValue < 25 && nl > 0) nl--;
|
||||
else if (this._fpsValue > 55 && nl < 2) nl++;
|
||||
if (nl !== this._qualityLevel) {
|
||||
this._qualityLevel = nl;
|
||||
this._nebula.setQuality(nl);
|
||||
this._postProcessing.setQuality(nl);
|
||||
}
|
||||
}
|
||||
|
||||
_onResize() {
|
||||
const w = window.innerWidth, h = window.innerHeight;
|
||||
this._camera.aspect = w / h;
|
||||
this._camera.updateProjectionMatrix();
|
||||
this._renderer.setSize(w, h);
|
||||
this._postProcessing.resize(w, h);
|
||||
}
|
||||
}
|
||||
|
||||
new Observatory();
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Room Atmosphere Background — Warm dark gradient with subtle particles
|
||||
* Matches RuView Foundation aesthetic: deep blue-black with warm undertones
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const BG_VERTEX = `
|
||||
varying vec3 vWorldPos;
|
||||
void main() {
|
||||
vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const BG_FRAGMENT = `
|
||||
uniform float uTime;
|
||||
uniform float uOctaves;
|
||||
varying vec3 vWorldPos;
|
||||
|
||||
vec3 hash33(vec3 p) {
|
||||
p = fract(p * vec3(443.8975, 397.2973, 491.1871));
|
||||
p += dot(p, p.yxz + 19.19);
|
||||
return fract(vec3(p.x * p.y, p.y * p.z, p.z * p.x));
|
||||
}
|
||||
|
||||
float noise3d(vec3 p) {
|
||||
vec3 i = floor(p);
|
||||
vec3 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
float n = mix(
|
||||
mix(mix(dot(hash33(i), f), dot(hash33(i + vec3(1,0,0)), f - vec3(1,0,0)), f.x),
|
||||
mix(dot(hash33(i + vec3(0,1,0)), f - vec3(0,1,0)), dot(hash33(i + vec3(1,1,0)), f - vec3(1,1,0)), f.x), f.y),
|
||||
mix(mix(dot(hash33(i + vec3(0,0,1)), f - vec3(0,0,1)), dot(hash33(i + vec3(1,0,1)), f - vec3(1,0,1)), f.x),
|
||||
mix(dot(hash33(i + vec3(0,1,1)), f - vec3(0,1,1)), dot(hash33(i + vec3(1,1,1)), f - vec3(1,1,1)), f.x), f.y),
|
||||
f.z);
|
||||
return n * 0.5 + 0.5;
|
||||
}
|
||||
|
||||
float fbm(vec3 p, float octaves) {
|
||||
float v = 0.0, a = 0.5;
|
||||
for (float i = 0.0; i < 5.0; i++) {
|
||||
if (i >= octaves) break;
|
||||
v += a * noise3d(p);
|
||||
p *= 2.0;
|
||||
a *= 0.5;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 dir = normalize(vWorldPos);
|
||||
|
||||
// Warm dark atmosphere with subtle color variation
|
||||
float n1 = fbm(dir * 2.5 + uTime * 0.008, uOctaves);
|
||||
float n2 = fbm(dir * 4.0 - uTime * 0.005, max(1.0, uOctaves - 1.0));
|
||||
|
||||
// Foundation palette: deep blue-black with warm undertones
|
||||
vec3 deepBlack = vec3(0.03, 0.04, 0.06);
|
||||
vec3 warmNavy = vec3(0.04, 0.05, 0.10);
|
||||
vec3 greenTint = vec3(0.01, 0.06, 0.04);
|
||||
|
||||
vec3 bg = mix(deepBlack, warmNavy, n1 * 0.5);
|
||||
bg = mix(bg, greenTint, n2 * 0.15);
|
||||
|
||||
// Subtle top-down gradient (lighter ceiling)
|
||||
float upFactor = max(0.0, dir.y) * 0.08;
|
||||
bg += vec3(0.02, 0.03, 0.05) * upFactor;
|
||||
|
||||
// Very subtle dim stars (distant)
|
||||
vec3 c = floor(dir * 200.0);
|
||||
vec3 h = hash33(c);
|
||||
float star = step(0.998, h.x) * h.y * 0.15;
|
||||
star *= 0.7 + 0.3 * sin(uTime * 1.5 + h.z * 80.0);
|
||||
bg += vec3(0.6, 0.7, 0.8) * star;
|
||||
|
||||
gl_FragColor = vec4(bg, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
export class NebulaBackground {
|
||||
constructor(scene) {
|
||||
this._octaves = 4;
|
||||
|
||||
this.uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uOctaves: { value: this._octaves },
|
||||
};
|
||||
|
||||
const geo = new THREE.SphereGeometry(150, 32, 32);
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: BG_VERTEX,
|
||||
fragmentShader: BG_FRAGMENT,
|
||||
uniforms: this.uniforms,
|
||||
side: THREE.BackSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
this.mesh = new THREE.Mesh(geo, mat);
|
||||
scene.add(this.mesh);
|
||||
}
|
||||
|
||||
update(dt, elapsed) {
|
||||
this.uniforms.uTime.value = elapsed;
|
||||
}
|
||||
|
||||
setQuality(level) {
|
||||
this._octaves = [2, 3, 4][level] || 4;
|
||||
this.uniforms.uOctaves.value = this._octaves;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.mesh.geometry.dispose();
|
||||
this.mesh.material.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Module D — "The Phase Constellation"
|
||||
* I/Q star map with constellation lines and rotating temporal view
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const NUM_SUBCARRIERS = 64;
|
||||
|
||||
export class PhaseConstellation {
|
||||
constructor(scene, panelGroup) {
|
||||
this.group = new THREE.Group();
|
||||
if (panelGroup) panelGroup.add(this.group);
|
||||
else scene.add(this.group);
|
||||
|
||||
// Star points (current frame)
|
||||
const starGeo = new THREE.BufferGeometry();
|
||||
this._positions = new Float32Array(NUM_SUBCARRIERS * 3);
|
||||
this._colors = new Float32Array(NUM_SUBCARRIERS * 3);
|
||||
this._sizes = new Float32Array(NUM_SUBCARRIERS);
|
||||
|
||||
starGeo.setAttribute('position', new THREE.BufferAttribute(this._positions, 3));
|
||||
starGeo.setAttribute('color', new THREE.BufferAttribute(this._colors, 3));
|
||||
starGeo.setAttribute('size', new THREE.BufferAttribute(this._sizes, 1));
|
||||
|
||||
const starMat = new THREE.PointsMaterial({
|
||||
size: 0.12,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
this._stars = new THREE.Points(starGeo, starMat);
|
||||
this.group.add(this._stars);
|
||||
|
||||
// Ghost layer (previous frame)
|
||||
const ghostGeo = new THREE.BufferGeometry();
|
||||
this._ghostPos = new Float32Array(NUM_SUBCARRIERS * 3);
|
||||
ghostGeo.setAttribute('position', new THREE.BufferAttribute(this._ghostPos, 3));
|
||||
|
||||
const ghostMat = new THREE.PointsMaterial({
|
||||
color: 0x00d4ff,
|
||||
size: 0.06,
|
||||
transparent: true,
|
||||
opacity: 0.2,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
this._ghosts = new THREE.Points(ghostGeo, ghostMat);
|
||||
this.group.add(this._ghosts);
|
||||
|
||||
// Constellation lines (connecting adjacent subcarriers)
|
||||
const lineGeo = new THREE.BufferGeometry();
|
||||
this._linePos = new Float32Array(NUM_SUBCARRIERS * 2 * 3); // pairs
|
||||
lineGeo.setAttribute('position', new THREE.BufferAttribute(this._linePos, 3));
|
||||
|
||||
const lineMat = new THREE.LineBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.15,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
this._lines = new THREE.LineSegments(lineGeo, lineMat);
|
||||
this.group.add(this._lines);
|
||||
|
||||
// Axes
|
||||
this._addAxes();
|
||||
|
||||
this._prevIQ = null;
|
||||
}
|
||||
|
||||
_addAxes() {
|
||||
const axesMat = new THREE.LineBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.1,
|
||||
});
|
||||
|
||||
// I axis
|
||||
const iGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-2.5, 0, 0),
|
||||
new THREE.Vector3(2.5, 0, 0),
|
||||
]);
|
||||
this.group.add(new THREE.Line(iGeo, axesMat));
|
||||
|
||||
// Q axis
|
||||
const qGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(0, -2.5, 0),
|
||||
new THREE.Vector3(0, 2.5, 0),
|
||||
]);
|
||||
this.group.add(new THREE.Line(qGeo, axesMat));
|
||||
}
|
||||
|
||||
update(dt, elapsed, data) {
|
||||
const iq = data?._observatory?.subcarrier_iq;
|
||||
const variance = data?._observatory?.per_subcarrier_variance;
|
||||
const amplitude = data?.nodes?.[0]?.amplitude;
|
||||
|
||||
// Slow Y rotation for temporal evolution
|
||||
this.group.rotation.y = elapsed * 0.05;
|
||||
|
||||
if (!iq || iq.length < NUM_SUBCARRIERS) return;
|
||||
|
||||
// Copy current to ghost
|
||||
this._ghostPos.set(this._positions);
|
||||
this._ghosts.geometry.attributes.position.needsUpdate = true;
|
||||
|
||||
// Update current positions from I/Q
|
||||
for (let s = 0; s < NUM_SUBCARRIERS; s++) {
|
||||
const i3 = s * 3;
|
||||
const iVal = (iq[s]?.i || 0) * 4; // scale for visibility
|
||||
const qVal = (iq[s]?.q || 0) * 4;
|
||||
|
||||
this._positions[i3] = iVal;
|
||||
this._positions[i3 + 1] = qVal;
|
||||
this._positions[i3 + 2] = 0;
|
||||
|
||||
// Size from amplitude
|
||||
const amp = amplitude ? (amplitude[s % amplitude.length] || 0.1) : 0.1;
|
||||
this._sizes[s] = 0.06 + amp * 0.15;
|
||||
|
||||
// Color from variance: blue(low) -> amber(high)
|
||||
const v = variance ? Math.min(1, (variance[s] || 0) * 2) : 0;
|
||||
this._colors[i3] = v * 1.0; // R
|
||||
this._colors[i3 + 1] = 0.5 + v * 0.3; // G
|
||||
this._colors[i3 + 2] = 1.0 - v * 0.7; // B
|
||||
}
|
||||
|
||||
this._stars.geometry.attributes.position.needsUpdate = true;
|
||||
this._stars.geometry.attributes.color.needsUpdate = true;
|
||||
this._stars.geometry.attributes.size.needsUpdate = true;
|
||||
|
||||
// Update constellation lines
|
||||
for (let s = 0; s < NUM_SUBCARRIERS - 1; s++) {
|
||||
const li = s * 6;
|
||||
const i3a = s * 3;
|
||||
const i3b = (s + 1) * 3;
|
||||
|
||||
this._linePos[li] = this._positions[i3a];
|
||||
this._linePos[li + 1] = this._positions[i3a + 1];
|
||||
this._linePos[li + 2] = this._positions[i3a + 2];
|
||||
this._linePos[li + 3] = this._positions[i3b];
|
||||
this._linePos[li + 4] = this._positions[i3b + 1];
|
||||
this._linePos[li + 5] = this._positions[i3b + 2];
|
||||
}
|
||||
// Last pair: wrap around
|
||||
const lastLi = (NUM_SUBCARRIERS - 1) * 6;
|
||||
const lastI3 = (NUM_SUBCARRIERS - 1) * 3;
|
||||
this._linePos[lastLi] = this._positions[lastI3];
|
||||
this._linePos[lastLi + 1] = this._positions[lastI3 + 1];
|
||||
this._linePos[lastLi + 2] = this._positions[lastI3 + 2];
|
||||
this._linePos[lastLi + 3] = this._positions[0];
|
||||
this._linePos[lastLi + 4] = this._positions[1];
|
||||
this._linePos[lastLi + 5] = this._positions[2];
|
||||
|
||||
this._lines.geometry.attributes.position.needsUpdate = true;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._stars.geometry.dispose();
|
||||
this._stars.material.dispose();
|
||||
this._ghosts.geometry.dispose();
|
||||
this._ghosts.material.dispose();
|
||||
this._lines.geometry.dispose();
|
||||
this._lines.material.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* Module C — "Presence Cartography"
|
||||
* InstancedMesh 20x4x20 voxel heatmap with person lights
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const GRID_X = 20;
|
||||
const GRID_Y = 4;
|
||||
const GRID_Z = 20;
|
||||
const TOTAL_VOXELS = GRID_X * GRID_Y * GRID_Z;
|
||||
const VOXEL_SIZE = 0.22;
|
||||
|
||||
export class PresenceCartography {
|
||||
constructor(scene, panelGroup) {
|
||||
this.group = new THREE.Group();
|
||||
if (panelGroup) panelGroup.add(this.group);
|
||||
else scene.add(this.group);
|
||||
|
||||
// Instanced cubes
|
||||
const cubeGeo = new THREE.BoxGeometry(VOXEL_SIZE, VOXEL_SIZE, VOXEL_SIZE);
|
||||
const cubeMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
|
||||
this._mesh = new THREE.InstancedMesh(cubeGeo, cubeMat, TOTAL_VOXELS);
|
||||
this._mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
|
||||
// Color attribute
|
||||
this._colors = new Float32Array(TOTAL_VOXELS * 3);
|
||||
this._mesh.instanceColor = new THREE.InstancedBufferAttribute(this._colors, 3);
|
||||
|
||||
// Initialize positions
|
||||
const dummy = new THREE.Object3D();
|
||||
const halfX = (GRID_X * VOXEL_SIZE * 1.1) / 2;
|
||||
const halfZ = (GRID_Z * VOXEL_SIZE * 1.1) / 2;
|
||||
|
||||
for (let y = 0; y < GRID_Y; y++) {
|
||||
for (let z = 0; z < GRID_Z; z++) {
|
||||
for (let x = 0; x < GRID_X; x++) {
|
||||
const idx = y * GRID_Z * GRID_X + z * GRID_X + x;
|
||||
dummy.position.set(
|
||||
x * VOXEL_SIZE * 1.1 - halfX,
|
||||
y * VOXEL_SIZE * 1.1,
|
||||
z * VOXEL_SIZE * 1.1 - halfZ
|
||||
);
|
||||
dummy.scale.set(0.01, 0.01, 0.01); // start invisible
|
||||
dummy.updateMatrix();
|
||||
this._mesh.setMatrixAt(idx, dummy.matrix);
|
||||
|
||||
this._colors[idx * 3] = 0;
|
||||
this._colors[idx * 3 + 1] = 0.2;
|
||||
this._colors[idx * 3 + 2] = 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
this._mesh.instanceMatrix.needsUpdate = true;
|
||||
this._mesh.instanceColor.needsUpdate = true;
|
||||
this.group.add(this._mesh);
|
||||
|
||||
// Room wireframe
|
||||
const roomW = GRID_X * VOXEL_SIZE * 1.1;
|
||||
const roomH = GRID_Y * VOXEL_SIZE * 1.1;
|
||||
const roomD = GRID_Z * VOXEL_SIZE * 1.1;
|
||||
const boxGeo = new THREE.BoxGeometry(roomW, roomH, roomD);
|
||||
const edges = new THREE.EdgesGeometry(boxGeo);
|
||||
const lineMat = new THREE.LineBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.15,
|
||||
});
|
||||
const wireframe = new THREE.LineSegments(edges, lineMat);
|
||||
wireframe.position.y = roomH / 2;
|
||||
this.group.add(wireframe);
|
||||
|
||||
// Person lights (up to 4)
|
||||
this._personLights = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const light = new THREE.PointLight(0xff8800, 0, 3);
|
||||
this.group.add(light);
|
||||
this._personLights.push(light);
|
||||
}
|
||||
|
||||
this._dummy = new THREE.Object3D();
|
||||
this._halfX = halfX;
|
||||
this._halfZ = halfZ;
|
||||
}
|
||||
|
||||
update(dt, elapsed, data) {
|
||||
const field = data?.signal_field?.values;
|
||||
const persons = data?.persons || [];
|
||||
|
||||
const dummy = this._dummy;
|
||||
|
||||
if (field && field.length >= GRID_X * GRID_Z) {
|
||||
for (let y = 0; y < GRID_Y; y++) {
|
||||
for (let z = 0; z < GRID_Z; z++) {
|
||||
for (let x = 0; x < GRID_X; x++) {
|
||||
const idx = y * GRID_Z * GRID_X + z * GRID_X + x;
|
||||
const fieldIdx = z * GRID_X + x;
|
||||
const val = field[fieldIdx] || 0;
|
||||
|
||||
// Extrude vertically: layer 0 = full val, higher layers diminish
|
||||
const layerFactor = Math.max(0, 1 - y / GRID_Y);
|
||||
const v = val * layerFactor;
|
||||
|
||||
// Scale voxel by value
|
||||
const s = v > 0.05 ? 0.3 + v * 0.7 : 0.01;
|
||||
dummy.position.set(
|
||||
x * VOXEL_SIZE * 1.1 - this._halfX,
|
||||
y * VOXEL_SIZE * 1.1,
|
||||
z * VOXEL_SIZE * 1.1 - this._halfZ
|
||||
);
|
||||
dummy.scale.set(s, s, s);
|
||||
dummy.updateMatrix();
|
||||
this._mesh.setMatrixAt(idx, dummy.matrix);
|
||||
|
||||
// Color: blue(low) -> cyan(mid) -> amber(high)
|
||||
let r, g, b;
|
||||
if (v < 0.3) {
|
||||
const t = v / 0.3;
|
||||
r = 0.02;
|
||||
g = 0.06 + t * 0.6;
|
||||
b = 0.2 + t * 0.6;
|
||||
} else if (v < 0.6) {
|
||||
const t = (v - 0.3) / 0.3;
|
||||
r = t * 0.8;
|
||||
g = 0.66 + t * 0.2;
|
||||
b = 0.8 - t * 0.5;
|
||||
} else {
|
||||
const t = (v - 0.6) / 0.4;
|
||||
r = 0.8 + t * 0.2;
|
||||
g = 0.86 - t * 0.5;
|
||||
b = 0.3 - t * 0.3;
|
||||
}
|
||||
this._colors[idx * 3] = r;
|
||||
this._colors[idx * 3 + 1] = g;
|
||||
this._colors[idx * 3 + 2] = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
this._mesh.instanceMatrix.needsUpdate = true;
|
||||
this._mesh.instanceColor.needsUpdate = true;
|
||||
}
|
||||
|
||||
// Person lights
|
||||
for (let i = 0; i < this._personLights.length; i++) {
|
||||
const light = this._personLights[i];
|
||||
if (i < persons.length) {
|
||||
const p = persons[i].position || [0, 0, 0];
|
||||
light.position.set(p[0] * 2, 1.5, p[2] * 2);
|
||||
light.intensity = 1.5 + Math.sin(elapsed * 3 + i) * 0.5;
|
||||
light.color.setHex(0xff8800);
|
||||
} else {
|
||||
light.intensity = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reduce voxel count for performance */
|
||||
setQuality(level) {
|
||||
// For now just toggle visibility of upper layers
|
||||
// level 0 = show only ground, 2 = show all
|
||||
this._mesh.count = level === 0
|
||||
? GRID_X * GRID_Z
|
||||
: level === 1
|
||||
? GRID_X * GRID_Z * 2
|
||||
: TOTAL_VOXELS;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._mesh.geometry.dispose();
|
||||
this._mesh.material.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Module A — "The Subcarrier Manifold"
|
||||
* 3D scrolling surface: 64 subcarriers x 60 time slots
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const MANIFOLD_VERTEX = `
|
||||
attribute float aHeight;
|
||||
attribute float aAge; // 0 = newest, 1 = oldest
|
||||
varying float vHeight;
|
||||
varying float vAge;
|
||||
void main() {
|
||||
vec3 pos = position;
|
||||
pos.y += aHeight * 2.0;
|
||||
vHeight = aHeight;
|
||||
vAge = aAge;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const MANIFOLD_FRAGMENT = `
|
||||
uniform float uTime;
|
||||
varying float vHeight;
|
||||
varying float vAge;
|
||||
void main() {
|
||||
// Color map: low=deep blue, mid=cyan, high=amber
|
||||
vec3 lo = vec3(0.02, 0.06, 0.2);
|
||||
vec3 mid = vec3(0.0, 0.83, 1.0);
|
||||
vec3 hi = vec3(1.0, 0.53, 0.0);
|
||||
|
||||
float h = clamp(vHeight, 0.0, 1.0);
|
||||
vec3 col = h < 0.5
|
||||
? mix(lo, mid, h * 2.0)
|
||||
: mix(mid, hi, (h - 0.5) * 2.0);
|
||||
|
||||
// Fade older rows
|
||||
float alpha = 0.3 + 0.7 * (1.0 - vAge);
|
||||
gl_FragColor = vec4(col, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
const SUBS = 64;
|
||||
const TIME_SLOTS = 60;
|
||||
|
||||
export class SubcarrierManifold {
|
||||
constructor(scene, panelGroup) {
|
||||
this.group = new THREE.Group();
|
||||
if (panelGroup) panelGroup.add(this.group);
|
||||
else scene.add(this.group);
|
||||
|
||||
this._history = []; // ring buffer of Float32Array[64]
|
||||
for (let i = 0; i < TIME_SLOTS; i++) {
|
||||
this._history.push(new Float32Array(SUBS));
|
||||
}
|
||||
this._head = 0;
|
||||
|
||||
// Build surface geometry
|
||||
const geo = new THREE.PlaneGeometry(8, 5, SUBS - 1, TIME_SLOTS - 1);
|
||||
const vertCount = SUBS * TIME_SLOTS;
|
||||
|
||||
this._heights = new Float32Array(vertCount);
|
||||
this._ages = new Float32Array(vertCount);
|
||||
for (let t = 0; t < TIME_SLOTS; t++) {
|
||||
for (let s = 0; s < SUBS; s++) {
|
||||
this._ages[t * SUBS + s] = t / TIME_SLOTS;
|
||||
}
|
||||
}
|
||||
|
||||
geo.setAttribute('aHeight', new THREE.BufferAttribute(this._heights, 1));
|
||||
geo.setAttribute('aAge', new THREE.BufferAttribute(this._ages, 1));
|
||||
|
||||
// Solid surface
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: MANIFOLD_VERTEX,
|
||||
fragmentShader: MANIFOLD_FRAGMENT,
|
||||
uniforms: { uTime: { value: 0 } },
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this._mesh = new THREE.Mesh(geo, mat);
|
||||
this._mesh.rotation.x = -Math.PI * 0.35;
|
||||
this.group.add(this._mesh);
|
||||
|
||||
// Wireframe overlay
|
||||
const wireGeo = geo.clone();
|
||||
wireGeo.setAttribute('aHeight', new THREE.BufferAttribute(this._heights, 1));
|
||||
wireGeo.setAttribute('aAge', new THREE.BufferAttribute(this._ages, 1));
|
||||
const wireMat = new THREE.ShaderMaterial({
|
||||
vertexShader: MANIFOLD_VERTEX,
|
||||
fragmentShader: `
|
||||
varying float vHeight;
|
||||
varying float vAge;
|
||||
void main() {
|
||||
float alpha = 0.15 * (1.0 - vAge);
|
||||
gl_FragColor = vec4(0.0, 0.83, 1.0, alpha);
|
||||
}
|
||||
`,
|
||||
uniforms: { uTime: { value: 0 } },
|
||||
transparent: true,
|
||||
wireframe: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this._wire = new THREE.Mesh(wireGeo, wireMat);
|
||||
this._wire.rotation.x = -Math.PI * 0.35;
|
||||
this.group.add(this._wire);
|
||||
|
||||
this._frameAccum = 0;
|
||||
this._pushInterval = 1 / 15; // push ~15 rows/sec
|
||||
}
|
||||
|
||||
update(dt, elapsed, data) {
|
||||
this._mesh.material.uniforms.uTime.value = elapsed;
|
||||
|
||||
// Push new amplitude data at regular intervals
|
||||
this._frameAccum += dt;
|
||||
if (this._frameAccum >= this._pushInterval && data) {
|
||||
this._frameAccum = 0;
|
||||
|
||||
const amp = data.nodes?.[0]?.amplitude;
|
||||
const row = new Float32Array(SUBS);
|
||||
if (amp && amp.length > 0) {
|
||||
for (let i = 0; i < SUBS; i++) {
|
||||
row[i] = amp[i % amp.length] || 0;
|
||||
}
|
||||
}
|
||||
|
||||
this._history[this._head] = row;
|
||||
this._head = (this._head + 1) % TIME_SLOTS;
|
||||
|
||||
this._rebuildHeights();
|
||||
}
|
||||
}
|
||||
|
||||
_rebuildHeights() {
|
||||
for (let t = 0; t < TIME_SLOTS; t++) {
|
||||
const histIdx = (this._head + t) % TIME_SLOTS;
|
||||
const row = this._history[histIdx];
|
||||
for (let s = 0; s < SUBS; s++) {
|
||||
const idx = t * SUBS + s;
|
||||
this._heights[idx] = row[s];
|
||||
this._ages[idx] = t / TIME_SLOTS;
|
||||
}
|
||||
}
|
||||
|
||||
const geo = this._mesh.geometry;
|
||||
geo.attributes.aHeight.needsUpdate = true;
|
||||
geo.attributes.aAge.needsUpdate = true;
|
||||
|
||||
const wGeo = this._wire.geometry;
|
||||
wGeo.attributes.aHeight.needsUpdate = true;
|
||||
wGeo.attributes.aAge.needsUpdate = true;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._mesh.geometry.dispose();
|
||||
this._mesh.material.dispose();
|
||||
this._wire.geometry.dispose();
|
||||
this._wire.material.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Module B — "Vital Signs Oracle"
|
||||
* Breathing/HR as orbital torus rings with beat markers + trail particles
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
export class VitalsOracle {
|
||||
constructor(scene, panelGroup) {
|
||||
this.group = new THREE.Group();
|
||||
if (panelGroup) panelGroup.add(this.group);
|
||||
else scene.add(this.group);
|
||||
|
||||
// Outer torus — breathing (violet)
|
||||
const breathGeo = new THREE.TorusGeometry(1.8, 0.06, 16, 64);
|
||||
this._breathMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x8844ff,
|
||||
transparent: true,
|
||||
opacity: 0.7,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
this._breathRing = new THREE.Mesh(breathGeo, this._breathMat);
|
||||
this._breathRing.rotation.x = Math.PI * 0.4;
|
||||
this.group.add(this._breathRing);
|
||||
|
||||
// Inner torus — heart rate (crimson)
|
||||
const hrGeo = new THREE.TorusGeometry(1.2, 0.04, 16, 64);
|
||||
this._hrMat = new THREE.MeshBasicMaterial({
|
||||
color: 0xff2244,
|
||||
transparent: true,
|
||||
opacity: 0.6,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
this._hrRing = new THREE.Mesh(hrGeo, this._hrMat);
|
||||
this._hrRing.rotation.x = Math.PI * 0.5;
|
||||
this._hrRing.rotation.z = Math.PI * 0.15;
|
||||
this.group.add(this._hrRing);
|
||||
|
||||
// Center orb
|
||||
const orbGeo = new THREE.SphereGeometry(0.35, 24, 24);
|
||||
this._orbMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x00d4ff,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
this._orb = new THREE.Mesh(orbGeo, this._orbMat);
|
||||
this.group.add(this._orb);
|
||||
|
||||
// Bloom point light
|
||||
this._light = new THREE.PointLight(0x00d4ff, 1.5, 8);
|
||||
this.group.add(this._light);
|
||||
|
||||
// Trail particles along breathing ring
|
||||
const trailCount = 120;
|
||||
const trailGeo = new THREE.BufferGeometry();
|
||||
const trailPos = new Float32Array(trailCount * 3);
|
||||
const trailSizes = new Float32Array(trailCount);
|
||||
for (let i = 0; i < trailCount; i++) {
|
||||
const angle = (i / trailCount) * Math.PI * 2;
|
||||
trailPos[i * 3] = Math.cos(angle) * 1.8;
|
||||
trailPos[i * 3 + 1] = 0;
|
||||
trailPos[i * 3 + 2] = Math.sin(angle) * 1.8;
|
||||
trailSizes[i] = 3;
|
||||
}
|
||||
trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPos, 3));
|
||||
trailGeo.setAttribute('size', new THREE.BufferAttribute(trailSizes, 1));
|
||||
|
||||
const trailMat = new THREE.PointsMaterial({
|
||||
color: 0x8844ff,
|
||||
size: 0.08,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
this._trails = new THREE.Points(trailGeo, trailMat);
|
||||
this._trails.rotation.x = Math.PI * 0.4;
|
||||
this.group.add(this._trails);
|
||||
|
||||
// Beat flash sprites
|
||||
this._beatFlash = this._createBeatSprite(0xff2244);
|
||||
this.group.add(this._beatFlash);
|
||||
this._beatTimer = 0;
|
||||
this._lastBeatTime = 0;
|
||||
|
||||
// State
|
||||
this._breathBpm = 0;
|
||||
this._hrBpm = 0;
|
||||
this._breathConf = 0;
|
||||
this._hrConf = 0;
|
||||
}
|
||||
|
||||
_createBeatSprite(color) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
|
||||
gradient.addColorStop(0, `rgba(255, 34, 68, 1)`);
|
||||
gradient.addColorStop(0.3, `rgba(255, 34, 68, 0.5)`);
|
||||
gradient.addColorStop(1, `rgba(255, 34, 68, 0)`);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, 64, 64);
|
||||
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
const mat = new THREE.SpriteMaterial({
|
||||
map: tex,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sprite = new THREE.Sprite(mat);
|
||||
sprite.scale.set(0, 0, 0);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
update(dt, elapsed, data) {
|
||||
const vs = data?.vital_signs || {};
|
||||
this._breathBpm = vs.breathing_rate_bpm || 0;
|
||||
this._hrBpm = vs.heart_rate_bpm || 0;
|
||||
this._breathConf = vs.breathing_confidence || 0;
|
||||
this._hrConf = vs.heart_rate_confidence || 0;
|
||||
|
||||
// Breathing ring pulsation
|
||||
const breathFreq = this._breathBpm / 60;
|
||||
const breathPulse = breathFreq > 0 ? Math.sin(elapsed * Math.PI * 2 * breathFreq) : 0;
|
||||
const breathScale = 1.0 + breathPulse * 0.08 * this._breathConf;
|
||||
this._breathRing.scale.set(breathScale, breathScale, 1);
|
||||
this._breathMat.opacity = 0.3 + this._breathConf * 0.5;
|
||||
|
||||
// HR ring pulsation (faster)
|
||||
const hrFreq = this._hrBpm / 60;
|
||||
const hrPulse = hrFreq > 0 ? Math.sin(elapsed * Math.PI * 2 * hrFreq) : 0;
|
||||
const hrScale = 1.0 + hrPulse * 0.06 * this._hrConf;
|
||||
this._hrRing.scale.set(hrScale, hrScale, 1);
|
||||
this._hrMat.opacity = 0.2 + this._hrConf * 0.5;
|
||||
|
||||
// Slow rotation
|
||||
this._breathRing.rotation.z = elapsed * 0.1;
|
||||
this._hrRing.rotation.z = -elapsed * 0.15;
|
||||
this._trails.rotation.z = elapsed * 0.1;
|
||||
|
||||
// Center orb pulse
|
||||
const orbPulse = 1.0 + breathPulse * 0.1;
|
||||
this._orb.scale.set(orbPulse, orbPulse, orbPulse);
|
||||
this._light.intensity = 0.8 + Math.abs(breathPulse) * 1.0;
|
||||
|
||||
// Beat flash on HR cycle
|
||||
if (hrFreq > 0) {
|
||||
this._beatTimer += dt;
|
||||
const beatInterval = 1 / hrFreq;
|
||||
if (this._beatTimer >= beatInterval) {
|
||||
this._beatTimer -= beatInterval;
|
||||
this._lastBeatTime = elapsed;
|
||||
}
|
||||
const beatAge = elapsed - this._lastBeatTime;
|
||||
const flashSize = Math.max(0, 1.2 - beatAge * 4) * this._hrConf;
|
||||
this._beatFlash.scale.set(flashSize, flashSize, 1);
|
||||
} else {
|
||||
this._beatFlash.scale.set(0, 0, 0);
|
||||
}
|
||||
|
||||
// Update trail particle sizes based on breathing
|
||||
const sizes = this._trails.geometry.attributes.size;
|
||||
if (sizes) {
|
||||
for (let i = 0; i < sizes.count; i++) {
|
||||
const phase = (i / sizes.count) * Math.PI * 2 + elapsed * breathFreq * Math.PI * 2;
|
||||
sizes.array[i] = 0.04 + Math.abs(Math.sin(phase)) * 0.06 * this._breathConf;
|
||||
}
|
||||
sizes.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._breathRing.geometry.dispose();
|
||||
this._breathMat.dispose();
|
||||
this._hrRing.geometry.dispose();
|
||||
this._hrMat.dispose();
|
||||
this._orb.geometry.dispose();
|
||||
this._orbMat.dispose();
|
||||
this._trails.geometry.dispose();
|
||||
this._trails.material.dispose();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Loading…
Reference in New Issue