1277 lines
43 KiB
HTML
1277 lines
43 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>RuView Spatial View</title>
|
|
<script>if (window.self === window.top) { window.location.replace('shell.html#spatial'); }</script>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
:root {
|
|
--bg: #050508;
|
|
--surface: rgba(10, 10, 15, 0.85);
|
|
--border: rgba(0, 204, 255, 0.2);
|
|
--green: #00ff88;
|
|
--cyan: #00ccff;
|
|
--red: #ff3333;
|
|
--yellow: #ffcc00;
|
|
--text: #e0e0e0;
|
|
--text-dim: #888;
|
|
--font-ui: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
--font-mono: 'JetBrains Mono', 'Berkeley Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
}
|
|
|
|
html, body { height: 100%; overflow: hidden; }
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: var(--font-ui);
|
|
}
|
|
|
|
#canvas3d {
|
|
position: fixed;
|
|
top: 0; left: 0;
|
|
width: 100%; height: 100%;
|
|
z-index: 0;
|
|
}
|
|
|
|
/* ── Overlay panels ───────────────────────────────────────────────── */
|
|
|
|
.overlay {
|
|
position: fixed;
|
|
z-index: 10;
|
|
background: var(--surface);
|
|
backdrop-filter: blur(10px);
|
|
-webkit-backdrop-filter: blur(10px);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 14px;
|
|
pointer-events: auto;
|
|
user-select: none;
|
|
}
|
|
|
|
/* ── Info panel (bottom-left) ─────────────────────────────────────── */
|
|
|
|
#infoPanel {
|
|
bottom: 16px;
|
|
left: 16px;
|
|
min-width: 260px;
|
|
max-width: 300px;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
#infoPanel h3 {
|
|
font-size: 0.7rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--cyan);
|
|
margin-bottom: 10px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.info-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 3px 0;
|
|
border-bottom: 1px solid rgba(255,255,255,0.04);
|
|
}
|
|
|
|
.info-row:last-child { border-bottom: none; }
|
|
|
|
.info-label {
|
|
color: var(--text-dim);
|
|
font-family: var(--font-ui);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.info-value {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.75rem;
|
|
color: var(--text);
|
|
text-align: right;
|
|
}
|
|
|
|
.info-value.status-ready { color: var(--green); }
|
|
.info-value.status-calib { color: var(--yellow); }
|
|
.info-value.status-wait { color: var(--red); }
|
|
|
|
.info-separator {
|
|
height: 1px;
|
|
background: rgba(255,255,255,0.06);
|
|
margin: 8px 0;
|
|
}
|
|
|
|
/* ── Controls panel (top-right) ───────────────────────────────────── */
|
|
|
|
#controlsPanel {
|
|
top: 16px;
|
|
right: 16px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.ctrl-btn {
|
|
background: rgba(255,255,255,0.04);
|
|
border: 1px solid var(--border);
|
|
color: var(--text-dim);
|
|
padding: 6px 14px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.72rem;
|
|
font-family: var(--font-mono);
|
|
text-align: left;
|
|
transition: all 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.ctrl-btn:hover { border-color: var(--cyan); color: var(--cyan); }
|
|
.ctrl-btn.active { border-color: var(--green); color: var(--green); background: rgba(0,255,136,0.06); }
|
|
|
|
/* ── Title (top-left) ─────────────────────────────────────────────── */
|
|
|
|
#titlePanel {
|
|
top: 16px;
|
|
left: 16px;
|
|
}
|
|
|
|
#titlePanel .title {
|
|
font-size: 1.1rem;
|
|
font-weight: 700;
|
|
color: var(--green);
|
|
font-family: var(--font-mono);
|
|
letter-spacing: -0.02em;
|
|
}
|
|
|
|
#titlePanel .subtitle {
|
|
font-size: 0.72rem;
|
|
color: var(--text-dim);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
/* ── FPS counter (top-center) ─────────────────────────────────────── */
|
|
|
|
#fpsCounter {
|
|
top: 16px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
padding: 4px 12px;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.7rem;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
/* ── Legend (bottom-right) ────────────────────────────────────────── */
|
|
|
|
#legend {
|
|
bottom: 16px;
|
|
right: 16px;
|
|
font-size: 0.7rem;
|
|
}
|
|
|
|
#legend h4 {
|
|
font-size: 0.65rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--text-dim);
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 2px 0;
|
|
color: var(--text-dim);
|
|
}
|
|
|
|
.legend-swatch {
|
|
width: 14px;
|
|
height: 10px;
|
|
border-radius: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ── Connection status banner ─────────────────────────────────────── */
|
|
|
|
#connectionBanner {
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0;
|
|
z-index: 20;
|
|
text-align: center;
|
|
padding: 6px;
|
|
font-family: var(--font-mono);
|
|
font-size: 0.72rem;
|
|
background: rgba(255,51,51,0.15);
|
|
color: var(--red);
|
|
border-bottom: 1px solid rgba(255,51,51,0.3);
|
|
display: none;
|
|
}
|
|
|
|
.beta-tag {
|
|
display: inline-block;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
color: #ffcc00;
|
|
background: rgba(255, 204, 0, 0.15);
|
|
border: 1px solid rgba(255, 204, 0, 0.35);
|
|
border-radius: 3px;
|
|
padding: 1px 5px;
|
|
margin-left: 6px;
|
|
vertical-align: super;
|
|
letter-spacing: 0.5px;
|
|
cursor: help;
|
|
}
|
|
.warning-note {
|
|
font-size: 11px;
|
|
color: #ff9999;
|
|
background: rgba(255, 68, 68, 0.1);
|
|
border: 1px solid rgba(255, 68, 68, 0.3);
|
|
border-radius: 4px;
|
|
padding: 4px 8px;
|
|
margin-top: 6px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<canvas id="canvas3d"></canvas>
|
|
|
|
<div id="connectionBanner">Waiting for spatial pipeline...</div>
|
|
|
|
<div id="titlePanel" class="overlay">
|
|
<div class="title">RuView Spatial<span class="beta-tag" title="Preview — voxel clustering is a single-blob demo, see ADR-044">β</span></div>
|
|
<div class="subtitle">RF Tomography Visualization</div>
|
|
<div class="subtitle" style="max-width:280px;line-height:1.3;margin-top:4px">RF tomography preview. Voxel clustering is a single-blob demo — multi-body resolution pending ADR-044.</div>
|
|
</div>
|
|
|
|
<div id="fpsCounter" class="overlay">-- FPS</div>
|
|
|
|
<div id="controlsPanel" class="overlay">
|
|
<button class="ctrl-btn active" data-toggle="voxels">Voxels</button>
|
|
<button class="ctrl-btn active" data-toggle="nodes">Nodes</button>
|
|
<button class="ctrl-btn active" data-toggle="links">Links</button>
|
|
<button class="ctrl-btn active" data-toggle="labels">Labels</button>
|
|
<button class="ctrl-btn active" data-toggle="presence">Presence</button>
|
|
<button class="ctrl-btn" data-action="resetView">Reset View</button>
|
|
<button class="ctrl-btn" data-action="fullscreen">Fullscreen</button>
|
|
</div>
|
|
|
|
<div id="infoPanel" class="overlay">
|
|
<h3>Spatial Status</h3>
|
|
<div class="info-row"><span class="info-label">Status</span><span class="info-value" id="valStatus">--</span></div>
|
|
<div class="info-row"><span class="info-label">Active Nodes</span><span class="info-value" id="valNodes">--</span></div>
|
|
<div class="info-row"><span class="info-label">Links</span><span class="info-value" id="valLinks">--</span></div>
|
|
<div class="info-row"><span class="info-label">Reconstructions</span><span class="info-value" id="valRecon">--</span></div>
|
|
<div class="info-row"><span class="info-label">Resolution</span><span class="info-value" id="valRes">--</span></div>
|
|
<div class="info-row"><span class="info-label">Residual</span><span class="info-value" id="valResidual">--</span></div>
|
|
<div class="info-separator"></div>
|
|
<div class="info-row"><span class="info-label">Presence (coarse)<span class="beta-tag" title="Not a validated body count — see ADR-044">β</span></span><span class="info-value" id="valPersons">0</span></div>
|
|
<div class="info-row"><span class="info-label">Breathing<span class="beta-tag" title="Single-node demo. Multi-node fusion pending.">β</span></span><span class="info-value" id="valBreath">--</span></div>
|
|
<div class="info-row"><span class="info-label">Heart Rate<span class="beta-tag" title="Single-node demo. Multi-node fusion pending.">β</span></span><span class="info-value" id="valHR">--</span></div>
|
|
<div class="warning-note">⚠ Tomography is currently a single-cluster blob (ADR-044)</div>
|
|
</div>
|
|
|
|
<div id="legend" class="overlay">
|
|
<h4>Voxel Density</h4>
|
|
<div class="legend-item"><span class="legend-swatch" style="background:#00ff88;opacity:0.4"></span>Low (0.01-0.3)</div>
|
|
<div class="legend-item"><span class="legend-swatch" style="background:#ffcc00;opacity:0.55"></span>Medium (0.3-0.6)</div>
|
|
<div class="legend-item"><span class="legend-swatch" style="background:#ff3333;opacity:0.7"></span>High (0.6-1.0)</div>
|
|
<div class="legend-item" style="margin-top:6px"><span class="legend-swatch" style="background:#00ccff;border-radius:50%"></span>Sensor Node</div>
|
|
<div class="legend-item" style="margin-top:6px"><span class="legend-swatch" style="background:#00ff88;opacity:0.8;border-radius:6px"></span>Standing</div>
|
|
<div class="legend-item"><span class="legend-swatch" style="background:#ffcc00;opacity:0.8;border-radius:6px"></span>Sitting</div>
|
|
<div class="legend-item"><span class="legend-swatch" style="background:#4488ff;opacity:0.8;border-radius:6px"></span>Lying</div>
|
|
</div>
|
|
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
|
|
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<script type="module">
|
|
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
|
|
// ── State ────────────────────────────────────────────────────────────
|
|
|
|
const state = {
|
|
bounds: [0, 0, 0, 6, 6, 3],
|
|
gridSize: [8, 8, 4],
|
|
nodes: [],
|
|
voxels: [],
|
|
status: null,
|
|
sensing: null,
|
|
vitals: null,
|
|
clusters: [],
|
|
visibility: { voxels: true, nodes: true, links: true, labels: true, presence: true },
|
|
wsConnected: false,
|
|
apiAvailable: false,
|
|
};
|
|
|
|
// ── Three.js setup ───────────────────────────────────────────────────
|
|
|
|
const canvas = document.getElementById('canvas3d');
|
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.setClearColor(0x050508, 1);
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 1.0;
|
|
|
|
const scene = new THREE.Scene();
|
|
scene.fog = new THREE.FogExp2(0x050508, 0.04);
|
|
|
|
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100);
|
|
camera.position.set(8, 6, 5);
|
|
|
|
const controls = new OrbitControls(camera, renderer.domElement);
|
|
controls.enableDamping = true;
|
|
controls.dampingFactor = 0.08;
|
|
controls.minDistance = 2;
|
|
controls.maxDistance = 30;
|
|
controls.target.set(3, 1.5, 3);
|
|
controls.update();
|
|
|
|
// ── Lighting ─────────────────────────────────────────────────────────
|
|
|
|
scene.add(new THREE.AmbientLight(0x222233, 0.6));
|
|
const dirLight = new THREE.DirectionalLight(0xffffff, 0.3);
|
|
dirLight.position.set(5, 10, 5);
|
|
scene.add(dirLight);
|
|
|
|
// ── Groups ───────────────────────────────────────────────────────────
|
|
|
|
const roomGroup = new THREE.Group();
|
|
const nodeGroup = new THREE.Group();
|
|
const linkGroup = new THREE.Group();
|
|
const labelGroup = new THREE.Group();
|
|
const voxelGroup = new THREE.Group();
|
|
const presenceGroup = new THREE.Group();
|
|
|
|
scene.add(roomGroup, nodeGroup, linkGroup, labelGroup, voxelGroup, presenceGroup);
|
|
|
|
// ── Coordinate mapping ───────────────────────────────────────────────
|
|
// API bounds: [x0, y0, z0, x1, y1, z1] where Z is height (0-3m ceiling)
|
|
// Three.js: Y-up. So API(x,y,z) -> Three.js(x, z, y)
|
|
function apiToWorld(ax, ay, az) { return new THREE.Vector3(ax, az, ay); }
|
|
|
|
// ── Room wireframe + floor grid ──────────────────────────────────────
|
|
|
|
function buildRoom() {
|
|
roomGroup.clear();
|
|
const [x0, y0, z0, x1, y1, z1] = state.bounds;
|
|
const sx = x1 - x0, sy = y1 - y0, sz = z1 - z0;
|
|
|
|
// Wireframe box — BoxGeometry(width=X, height=Z_up, depth=Y)
|
|
const boxGeo = new THREE.BoxGeometry(sx, sz, sy);
|
|
const boxEdges = new THREE.EdgesGeometry(boxGeo);
|
|
const boxLine = new THREE.LineSegments(boxEdges, new THREE.LineBasicMaterial({
|
|
color: 0x00ccff, transparent: true, opacity: 0.25
|
|
}));
|
|
boxLine.position.copy(apiToWorld(x0 + sx / 2, y0 + sy / 2, z0 + sz / 2));
|
|
roomGroup.add(boxLine);
|
|
|
|
// Floor grid at z=0 (ground plane) -> Three.js Y=0
|
|
const gridSpacing = 0.5;
|
|
const gridMat = new THREE.LineBasicMaterial({ color: 0x00ccff, transparent: true, opacity: 0.08 });
|
|
const gridPts = [];
|
|
for (let gx = x0; gx <= x1 + 0.001; gx += gridSpacing) {
|
|
gridPts.push(apiToWorld(gx, y0, z0), apiToWorld(gx, y1, z0));
|
|
}
|
|
for (let gy = y0; gy <= y1 + 0.001; gy += gridSpacing) {
|
|
gridPts.push(apiToWorld(x0, gy, z0), apiToWorld(x1, gy, z0));
|
|
}
|
|
const gridGeo = new THREE.BufferGeometry().setFromPoints(gridPts);
|
|
roomGroup.add(new THREE.LineSegments(gridGeo, gridMat));
|
|
|
|
// Axis lines at origin corner
|
|
const axisLen = 1.0;
|
|
const origin = apiToWorld(x0, y0, z0);
|
|
const axes = [
|
|
{ end: apiToWorld(x0 + axisLen, y0, z0), color: 0xff4444, label: 'X' },
|
|
{ end: apiToWorld(x0, y0 + axisLen, z0), color: 0x44ff44, label: 'Y' },
|
|
{ end: apiToWorld(x0, y0, z0 + axisLen), color: 0x4488ff, label: 'Z (up)' },
|
|
];
|
|
axes.forEach(a => {
|
|
const geo = new THREE.BufferGeometry().setFromPoints([origin, a.end]);
|
|
roomGroup.add(new THREE.LineSegments(geo, new THREE.LineBasicMaterial({ color: a.color, opacity: 0.7, transparent: true })));
|
|
});
|
|
|
|
// Scale labels using sprites
|
|
const createTextSprite = (text, position, color = '#888888', size = 0.22) => {
|
|
const cvs = document.createElement('canvas');
|
|
cvs.width = 128; cvs.height = 48;
|
|
const ctx = cvs.getContext('2d');
|
|
ctx.font = 'bold 28px monospace';
|
|
ctx.fillStyle = color;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, 64, 24);
|
|
const tex = new THREE.CanvasTexture(cvs);
|
|
tex.minFilter = THREE.LinearFilter;
|
|
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
|
|
const sprite = new THREE.Sprite(mat);
|
|
sprite.position.copy(position);
|
|
sprite.scale.set(size * 3, size * 1.1, 1);
|
|
return sprite;
|
|
};
|
|
|
|
// X axis labels (red, along floor front edge)
|
|
for (let v = Math.ceil(x0); v <= x1; v++) {
|
|
roomGroup.add(createTextSprite(v + 'm', apiToWorld(v, y0 - 0.3, z0 - 0.15), '#ff6666'));
|
|
}
|
|
// Y axis labels (green, along floor left edge)
|
|
for (let v = Math.ceil(y0); v <= y1; v++) {
|
|
roomGroup.add(createTextSprite(v + 'm', apiToWorld(x0 - 0.4, v, z0 - 0.15), '#66ff66'));
|
|
}
|
|
// Z axis labels (blue, height along left-front vertical edge)
|
|
for (let v = Math.ceil(z0); v <= z1; v++) {
|
|
roomGroup.add(createTextSprite(v + 'm', apiToWorld(x0 - 0.4, y0 - 0.3, v), '#6688ff'));
|
|
}
|
|
|
|
// Axis name labels
|
|
const off = axisLen + 0.25;
|
|
roomGroup.add(createTextSprite('X', apiToWorld(x0 + off, y0, z0), '#ff4444', 0.3));
|
|
roomGroup.add(createTextSprite('Y', apiToWorld(x0, y0 + off, z0), '#44ff44', 0.3));
|
|
roomGroup.add(createTextSprite('Z', apiToWorld(x0, y0, z0 + off), '#4488ff', 0.3));
|
|
}
|
|
|
|
// ── Node markers ─────────────────────────────────────────────────────
|
|
|
|
const nodeSphereGeo = new THREE.SphereGeometry(0.15, 16, 12);
|
|
const nodeMatOnline = new THREE.MeshStandardMaterial({
|
|
color: 0x00ccff, emissive: 0x00ccff, emissiveIntensity: 0.6, transparent: true, opacity: 0.9
|
|
});
|
|
const nodeMatOffline = new THREE.MeshStandardMaterial({
|
|
color: 0x444444, emissive: 0x222222, emissiveIntensity: 0.1, transparent: true, opacity: 0.5
|
|
});
|
|
|
|
function createNodeLabel(text) {
|
|
const cvs = document.createElement('canvas');
|
|
cvs.width = 192; cvs.height = 48;
|
|
const ctx = cvs.getContext('2d');
|
|
ctx.font = 'bold 26px monospace';
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, 96, 24);
|
|
const tex = new THREE.CanvasTexture(cvs);
|
|
tex.minFilter = THREE.LinearFilter;
|
|
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false, opacity: 0.8 });
|
|
const sprite = new THREE.Sprite(mat);
|
|
sprite.scale.set(0.8, 0.2, 1);
|
|
return sprite;
|
|
}
|
|
|
|
function rebuildNodes() {
|
|
// Dispose old
|
|
nodeGroup.children.forEach(c => {
|
|
if (c.geometry && !c.geometry._shared) c.geometry.dispose();
|
|
if (c.material && c.material.map) c.material.map.dispose();
|
|
if (c.material) c.material.dispose();
|
|
if (c.userData.light) {
|
|
scene.remove(c.userData.light);
|
|
c.userData.light.dispose();
|
|
}
|
|
});
|
|
nodeGroup.clear();
|
|
labelGroup.children.forEach(c => {
|
|
if (c.material && c.material.map) c.material.map.dispose();
|
|
if (c.material) c.material.dispose();
|
|
});
|
|
labelGroup.clear();
|
|
|
|
state.nodes.forEach(n => {
|
|
const [px, py, pz] = n.position;
|
|
const online = n.confidence > 0.1;
|
|
const mesh = new THREE.Mesh(nodeSphereGeo, online ? nodeMatOnline.clone() : nodeMatOffline.clone());
|
|
mesh.position.copy(apiToWorld(px, py, pz));
|
|
mesh.userData.nodeId = n.node_id;
|
|
mesh.userData.online = online;
|
|
mesh.userData.pulsePhase = Math.random() * Math.PI * 2;
|
|
|
|
// Point light
|
|
if (online) {
|
|
const light = new THREE.PointLight(0x00ccff, 0.3, 3);
|
|
light.position.copy(mesh.position);
|
|
scene.add(light);
|
|
mesh.userData.light = light;
|
|
}
|
|
|
|
nodeGroup.add(mesh);
|
|
|
|
// Label
|
|
const label = createNodeLabel('Node ' + n.node_id);
|
|
label.position.copy(apiToWorld(px, py, pz + 0.35));
|
|
labelGroup.add(label);
|
|
});
|
|
}
|
|
|
|
// ── Signal links ─────────────────────────────────────────────────────
|
|
|
|
function rebuildLinks() {
|
|
linkGroup.children.forEach(c => {
|
|
if (c.geometry) c.geometry.dispose();
|
|
if (c.material) c.material.dispose();
|
|
});
|
|
linkGroup.clear();
|
|
|
|
const lineMat = new THREE.LineBasicMaterial({ color: 0x00ff88, transparent: true, opacity: 0.12 });
|
|
const lineMatGray = new THREE.LineBasicMaterial({ color: 0x444444, transparent: true, opacity: 0.06 });
|
|
|
|
for (let i = 0; i < state.nodes.length; i++) {
|
|
for (let j = i + 1; j < state.nodes.length; j++) {
|
|
const a = state.nodes[i], b = state.nodes[j];
|
|
const bothOnline = a.confidence > 0.1 && b.confidence > 0.1;
|
|
const pts = [
|
|
apiToWorld(a.position[0], a.position[1], a.position[2]),
|
|
apiToWorld(b.position[0], b.position[1], b.position[2]),
|
|
];
|
|
const geo = new THREE.BufferGeometry().setFromPoints(pts);
|
|
linkGroup.add(new THREE.Line(geo, bothOnline ? lineMat.clone() : lineMatGray.clone()));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Voxel heatmap ────────────────────────────────────────────────────
|
|
|
|
let instancedVoxelMesh = null;
|
|
const voxelDummy = new THREE.Object3D();
|
|
|
|
function densityToColor(d) {
|
|
if (d <= 0.0) return null;
|
|
if (d <= 0.3) return { r: 0, g: 1, b: 0.533, hex: 0x00ff88 }; // green
|
|
if (d <= 0.6) return { r: 1, g: 0.8, b: 0, hex: 0xffcc00 }; // yellow
|
|
return { r: 1, g: 0.2, b: 0.2, hex: 0xff3333 }; // red
|
|
}
|
|
|
|
function rebuildVoxels() {
|
|
if (instancedVoxelMesh) {
|
|
voxelGroup.remove(instancedVoxelMesh);
|
|
instancedVoxelMesh.geometry.dispose();
|
|
instancedVoxelMesh.material.dispose();
|
|
instancedVoxelMesh = null;
|
|
}
|
|
|
|
const occupied = state.voxels.filter(v => v.density > 0.005);
|
|
if (occupied.length === 0) return;
|
|
|
|
const [x0, y0, z0, x1, y1, z1] = state.bounds;
|
|
const [gx, gy, gz] = state.gridSize;
|
|
const vw = (x1 - x0) / gx;
|
|
const vh = (y1 - y0) / gy;
|
|
const vd = (z1 - z0) / gz;
|
|
|
|
const geo = new THREE.BoxGeometry(vw * 0.92, vd * 0.92, vh * 0.92);
|
|
const mat = new THREE.MeshBasicMaterial({
|
|
transparent: true,
|
|
vertexColors: false,
|
|
depthWrite: false,
|
|
side: THREE.FrontSide,
|
|
});
|
|
|
|
instancedVoxelMesh = new THREE.InstancedMesh(geo, mat, occupied.length);
|
|
instancedVoxelMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
|
|
// Per-instance color
|
|
const colorAttr = new THREE.InstancedBufferAttribute(new Float32Array(occupied.length * 3), 3);
|
|
instancedVoxelMesh.instanceColor = colorAttr;
|
|
|
|
occupied.forEach((v, i) => {
|
|
const [cx, cy, cz] = v.center;
|
|
voxelDummy.position.copy(apiToWorld(cx, cy, cz));
|
|
voxelDummy.updateMatrix();
|
|
instancedVoxelMesh.setMatrixAt(i, voxelDummy.matrix);
|
|
|
|
const c = densityToColor(v.density);
|
|
if (c) {
|
|
colorAttr.setXYZ(i, c.r, c.g, c.b);
|
|
}
|
|
|
|
// Use custom shader for per-instance opacity via uniform workaround:
|
|
// Since InstancedMesh doesn't support per-instance opacity natively,
|
|
// we encode opacity into alpha of the material and use a single pass.
|
|
// For simplicity, set material opacity to average and rely on color intensity.
|
|
});
|
|
|
|
// Set material opacity to a mid-range; color intensity conveys density
|
|
const avgDensity = occupied.reduce((s, v) => s + v.density, 0) / occupied.length;
|
|
mat.opacity = 0.1 + avgDensity * 0.5;
|
|
|
|
instancedVoxelMesh.instanceMatrix.needsUpdate = true;
|
|
instancedVoxelMesh.instanceColor.needsUpdate = true;
|
|
instancedVoxelMesh.renderOrder = -1;
|
|
voxelGroup.add(instancedVoxelMesh);
|
|
}
|
|
|
|
// For < 50 voxels, use individual meshes with per-voxel opacity
|
|
function rebuildVoxelsIndividual() {
|
|
voxelGroup.children.forEach(c => {
|
|
if (c.geometry) c.geometry.dispose();
|
|
if (c.material) c.material.dispose();
|
|
});
|
|
voxelGroup.clear();
|
|
|
|
const [x0, y0, z0, x1, y1, z1] = state.bounds;
|
|
const [gx, gy, gz] = state.gridSize;
|
|
const vw = (x1 - x0) / gx;
|
|
const vh = (y1 - y0) / gy;
|
|
const vd = (z1 - z0) / gz;
|
|
|
|
const boxGeo = new THREE.BoxGeometry(vw * 0.92, vd * 0.92, vh * 0.92);
|
|
|
|
state.voxels.forEach(v => {
|
|
if (v.density <= 0.005) return;
|
|
const c = densityToColor(v.density);
|
|
if (!c) return;
|
|
|
|
const mat = new THREE.MeshBasicMaterial({
|
|
color: c.hex,
|
|
transparent: true,
|
|
opacity: 0.1 + v.density * 0.6,
|
|
depthWrite: false,
|
|
side: THREE.FrontSide,
|
|
});
|
|
const mesh = new THREE.Mesh(boxGeo, mat);
|
|
mesh.position.copy(apiToWorld(v.center[0], v.center[1], v.center[2]));
|
|
mesh.renderOrder = -1;
|
|
voxelGroup.add(mesh);
|
|
});
|
|
}
|
|
|
|
function updateVoxels() {
|
|
if (state.voxels.length > 50) {
|
|
rebuildVoxels();
|
|
} else {
|
|
rebuildVoxelsIndividual();
|
|
}
|
|
}
|
|
|
|
// ── Stick figure rendering ──────────────────────────────────────────
|
|
|
|
const STANDING_KEYPOINTS = [
|
|
[0, 1.7, 0], // 0: nose
|
|
[-0.03, 1.72, 0], // 1: left_eye
|
|
[0.03, 1.72, 0], // 2: right_eye
|
|
[-0.08, 1.68, 0], // 3: left_ear
|
|
[0.08, 1.68, 0], // 4: right_ear
|
|
[-0.2, 1.4, 0], // 5: left_shoulder
|
|
[0.2, 1.4, 0], // 6: right_shoulder
|
|
[-0.35, 1.1, 0], // 7: left_elbow
|
|
[0.35, 1.1, 0], // 8: right_elbow
|
|
[-0.35, 0.8, 0], // 9: left_wrist
|
|
[0.35, 0.8, 0], // 10: right_wrist
|
|
[-0.15, 0.85, 0], // 11: left_hip
|
|
[0.15, 0.85, 0], // 12: right_hip
|
|
[-0.15, 0.45, 0], // 13: left_knee
|
|
[0.15, 0.45, 0], // 14: right_knee
|
|
[-0.15, 0.0, 0], // 15: left_ankle
|
|
[0.15, 0.0, 0], // 16: right_ankle
|
|
];
|
|
|
|
const BONES = [
|
|
[0, 1], [0, 2], [1, 3], [2, 4],
|
|
[0, 5], [0, 6],
|
|
[5, 6],
|
|
[5, 7], [7, 9],
|
|
[6, 8], [8, 10],
|
|
[5, 11], [6, 12],
|
|
[11, 12],
|
|
[11, 13], [13, 15],
|
|
[12, 14], [14, 16],
|
|
];
|
|
|
|
function getKeypoints(poseHint, heightScale) {
|
|
let kps = STANDING_KEYPOINTS.map(k => [k[0] * heightScale, k[1] * heightScale, k[2] * heightScale]);
|
|
if (poseHint === 'sitting') {
|
|
kps = STANDING_KEYPOINTS.map(k => [k[0] * heightScale, k[1] * heightScale * 0.6, k[2] * heightScale]);
|
|
kps[11] = [-0.15 * heightScale, 0.5 * heightScale, 0];
|
|
kps[12] = [0.15 * heightScale, 0.5 * heightScale, 0];
|
|
kps[13] = [-0.15 * heightScale, 0.35 * heightScale, 0.2 * heightScale];
|
|
kps[14] = [0.15 * heightScale, 0.35 * heightScale, 0.2 * heightScale];
|
|
kps[15] = [-0.15 * heightScale, 0.35 * heightScale, 0.35 * heightScale];
|
|
kps[16] = [0.15 * heightScale, 0.35 * heightScale, 0.35 * heightScale];
|
|
} else if (poseHint === 'lying') {
|
|
// Rotate 90 degrees: height -> depth, figure horizontal
|
|
kps = STANDING_KEYPOINTS.map(k => [k[0] * heightScale, 0.15 * heightScale, k[1] * heightScale]);
|
|
}
|
|
return kps;
|
|
}
|
|
|
|
function createBodyLabel(text, color) {
|
|
const cvs = document.createElement('canvas');
|
|
cvs.width = 192; cvs.height = 48;
|
|
const ctx = cvs.getContext('2d');
|
|
ctx.font = 'bold 22px monospace';
|
|
ctx.fillStyle = '#' + color.toString(16).padStart(6, '0');
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, 96, 24);
|
|
const tex = new THREE.CanvasTexture(cvs);
|
|
tex.minFilter = THREE.LinearFilter;
|
|
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false });
|
|
const sprite = new THREE.Sprite(mat);
|
|
sprite.scale.set(0.7, 0.18, 1);
|
|
return sprite;
|
|
}
|
|
|
|
function poseColor(poseHint) {
|
|
return poseHint === 'standing' ? 0x00ff88 :
|
|
poseHint === 'sitting' ? 0xffcc00 : 0x4488ff;
|
|
}
|
|
|
|
function createStickFigure(cluster) {
|
|
const group = new THREE.Group();
|
|
|
|
const zExtent = cluster.bbox_max[2] - cluster.bbox_min[2];
|
|
const heightScale = Math.max(zExtent, 0.5) / 1.7;
|
|
|
|
const keypoints = getKeypoints(cluster.pose_hint, heightScale);
|
|
|
|
const color = poseColor(cluster.pose_hint);
|
|
|
|
// Bones as line segments (positioned at local origin)
|
|
const points = [];
|
|
BONES.forEach(([a, b]) => {
|
|
const ka = keypoints[a], kb = keypoints[b];
|
|
points.push(
|
|
new THREE.Vector3(ka[0], ka[2], ka[1]),
|
|
new THREE.Vector3(kb[0], kb[2], kb[1])
|
|
);
|
|
});
|
|
const geo = new THREE.BufferGeometry().setFromPoints(points);
|
|
const mat = new THREE.LineBasicMaterial({ color, linewidth: 2, transparent: true, opacity: 0.8 });
|
|
const bones = new THREE.LineSegments(geo, mat);
|
|
group.add(bones);
|
|
|
|
// Joint spheres
|
|
const jointGeo = new THREE.SphereGeometry(0.04, 8, 6);
|
|
const jointMat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.9 });
|
|
keypoints.forEach(kp => {
|
|
const joint = new THREE.Mesh(jointGeo, jointMat.clone());
|
|
joint.position.set(kp[0], kp[2], kp[1]);
|
|
group.add(joint);
|
|
});
|
|
|
|
// Head sphere (larger)
|
|
const headGeo = new THREE.SphereGeometry(0.1, 12, 8);
|
|
const headMat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.7 });
|
|
const head = new THREE.Mesh(headGeo, headMat);
|
|
head.position.set(keypoints[0][0], keypoints[0][2], keypoints[0][1]);
|
|
group.add(head);
|
|
|
|
// Label
|
|
const label = createBodyLabel(`body_${cluster.id + 1}`, color);
|
|
label.position.set(0, keypoints[0][2] + 0.25, 0);
|
|
group.add(label);
|
|
|
|
// Store references for in-place updates
|
|
group.userData.bonesLine = bones;
|
|
group.userData.headMesh = head;
|
|
group.userData.labelSprite = label;
|
|
|
|
group.userData.phase = Math.random() * Math.PI * 2;
|
|
group.userData.zExtent = zExtent;
|
|
return group;
|
|
}
|
|
|
|
// Rebuild bone/joint geometry in-place for pose transitions
|
|
function rebuildFigureGeometry(fig) {
|
|
const group = fig.group;
|
|
const keypoints = fig.currentKeypoints;
|
|
const color = poseColor(fig.targetPose);
|
|
|
|
// Update bones
|
|
const bonesLine = group.userData.bonesLine;
|
|
const bonePoints = [];
|
|
BONES.forEach(([a, b]) => {
|
|
const ka = keypoints[a], kb = keypoints[b];
|
|
bonePoints.push(
|
|
new THREE.Vector3(ka[0], ka[2], ka[1]),
|
|
new THREE.Vector3(kb[0], kb[2], kb[1])
|
|
);
|
|
});
|
|
bonesLine.geometry.dispose();
|
|
bonesLine.geometry = new THREE.BufferGeometry().setFromPoints(bonePoints);
|
|
bonesLine.material.color.setHex(color);
|
|
|
|
// Update joints (children indices 1..17 are joints, 18 is head, 19 is label)
|
|
let jointIdx = 0;
|
|
group.children.forEach(child => {
|
|
if (child === bonesLine || child === group.userData.headMesh || child === group.userData.labelSprite) return;
|
|
if (child.isMesh && child.geometry.type === 'SphereGeometry' && child !== group.userData.headMesh) {
|
|
if (jointIdx < keypoints.length) {
|
|
const kp = keypoints[jointIdx];
|
|
child.position.set(kp[0], kp[2], kp[1]);
|
|
child.material.color.setHex(color);
|
|
jointIdx++;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Update head
|
|
const head = group.userData.headMesh;
|
|
head.position.set(keypoints[0][0], keypoints[0][2], keypoints[0][1]);
|
|
head.material.color.setHex(color);
|
|
|
|
// Update label position
|
|
const label = group.userData.labelSprite;
|
|
label.position.set(0, keypoints[0][2] + 0.25, 0);
|
|
}
|
|
|
|
// ── Persistent figure state ────────────────────────────────────────
|
|
|
|
const activeFigures = new Map();
|
|
|
|
class FigureState {
|
|
constructor(cluster) {
|
|
this.id = cluster.id;
|
|
this.group = createStickFigure(cluster);
|
|
this.targetCentroid = [...cluster.centroid];
|
|
this.currentCentroid = [...cluster.centroid];
|
|
this.targetPose = cluster.pose_hint;
|
|
this.prevPose = cluster.pose_hint;
|
|
this.poseBlend = 1.0;
|
|
this.phase = this.group.userData.phase;
|
|
this.quality = 1.0;
|
|
this.zExtent = cluster.bbox_max[2] - cluster.bbox_min[2];
|
|
this.heightScale = Math.max(this.zExtent, 0.5) / 1.7;
|
|
|
|
// Keypoint state for smooth transitions
|
|
this.currentKeypoints = getKeypoints(cluster.pose_hint, this.heightScale);
|
|
this.targetKeypoints = this.currentKeypoints.map(k => [...k]);
|
|
this.prevKeypoints = this.currentKeypoints.map(k => [...k]);
|
|
|
|
// Position the group at the centroid
|
|
const baseZ = cluster.centroid[2] - this.zExtent / 2;
|
|
this.group.position.copy(apiToWorld(cluster.centroid[0], cluster.centroid[1], baseZ));
|
|
|
|
presenceGroup.add(this.group);
|
|
}
|
|
|
|
update(cluster) {
|
|
this.targetCentroid = [...cluster.centroid];
|
|
this.zExtent = cluster.bbox_max[2] - cluster.bbox_min[2];
|
|
this.heightScale = Math.max(this.zExtent, 0.5) / 1.7;
|
|
|
|
if (cluster.pose_hint !== this.targetPose) {
|
|
this.prevPose = this.targetPose;
|
|
this.prevKeypoints = this.currentKeypoints.map(k => [...k]);
|
|
this.targetPose = cluster.pose_hint;
|
|
this.poseBlend = 0;
|
|
}
|
|
this.targetKeypoints = getKeypoints(this.targetPose, this.heightScale);
|
|
this.quality = 1.0;
|
|
}
|
|
|
|
remove() {
|
|
presenceGroup.remove(this.group);
|
|
this.group.traverse(child => {
|
|
if (child.geometry) child.geometry.dispose();
|
|
if (child.material) {
|
|
if (child.material.map) child.material.map.dispose();
|
|
child.material.dispose();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function updateStickFigures() {
|
|
const seenIds = new Set();
|
|
|
|
const clustersToProcess = state.clusters.length > 0 ? state.clusters : [];
|
|
|
|
// Fallback: single figure from voxel centroid when no clusters
|
|
if (clustersToProcess.length === 0 && state.voxels.length > 0 && state.sensing?.estimated_persons > 0) {
|
|
let sx = 0, sy = 0, sz = 0;
|
|
state.voxels.forEach(v => { sx += v.center[0]; sy += v.center[1]; sz += v.center[2]; });
|
|
const n = state.voxels.length;
|
|
clustersToProcess.push({
|
|
id: 0,
|
|
centroid: [sx / n, sy / n, sz / n],
|
|
bbox_min: [sx / n - 0.3, sy / n - 0.3, 0],
|
|
bbox_max: [sx / n + 0.3, sy / n + 0.3, 1.7],
|
|
pose_hint: 'standing',
|
|
});
|
|
}
|
|
|
|
for (const cluster of clustersToProcess) {
|
|
seenIds.add(cluster.id);
|
|
if (activeFigures.has(cluster.id)) {
|
|
activeFigures.get(cluster.id).update(cluster);
|
|
} else {
|
|
activeFigures.set(cluster.id, new FigureState(cluster));
|
|
}
|
|
}
|
|
|
|
// Fade out missing figures
|
|
for (const [id, fig] of activeFigures) {
|
|
if (!seenIds.has(id)) {
|
|
fig.quality -= 0.05;
|
|
if (fig.quality <= 0) {
|
|
fig.remove();
|
|
activeFigures.delete(id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Build initial room ───────────────────────────────────────────────
|
|
|
|
buildRoom();
|
|
|
|
// ── Data fetching ────────────────────────────────────────────────────
|
|
|
|
const API_BASE = '';
|
|
const banner = document.getElementById('connectionBanner');
|
|
|
|
async function fetchJSON(url) {
|
|
try {
|
|
const r = await fetch(API_BASE + url);
|
|
if (!r.ok) throw new Error(r.status);
|
|
return await r.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function pollVolume() {
|
|
const data = await fetchJSON('/api/v1/spatial/volume');
|
|
if (data) {
|
|
state.apiAvailable = true;
|
|
banner.style.display = 'none';
|
|
if (data.grid_size) state.gridSize = data.grid_size;
|
|
if (data.bounds) state.bounds = data.bounds;
|
|
state.voxels = data.occupied_voxels || [];
|
|
updateVoxels();
|
|
updateInfoPanel(data);
|
|
} else if (!state.wsConnected) {
|
|
state.apiAvailable = false;
|
|
banner.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
async function pollStatus() {
|
|
const data = await fetchJSON('/api/v1/spatial/status');
|
|
if (data) {
|
|
state.status = data;
|
|
updateStatusInfo();
|
|
}
|
|
}
|
|
|
|
async function pollNodes() {
|
|
const data = await fetchJSON('/api/v1/spatial/nodes');
|
|
if (data && data.nodes) {
|
|
state.nodes = data.nodes;
|
|
rebuildNodes();
|
|
rebuildLinks();
|
|
}
|
|
}
|
|
|
|
async function pollVitals() {
|
|
const data = await fetchJSON('/api/v1/vital-signs');
|
|
if (data) {
|
|
state.vitals = data;
|
|
updateVitalsInfo();
|
|
}
|
|
}
|
|
|
|
async function pollSensing() {
|
|
const data = await fetchJSON('/api/v1/sensing/latest');
|
|
if (data) {
|
|
state.sensing = data;
|
|
updateSensingInfo();
|
|
}
|
|
}
|
|
|
|
async function pollClusters() {
|
|
const data = await fetchJSON('/api/v1/spatial/clusters');
|
|
if (data && data.clusters) {
|
|
state.clusters = data.clusters;
|
|
updateStickFigures();
|
|
}
|
|
}
|
|
|
|
// ── WebSocket ────────────────────────────────────────────────────────
|
|
|
|
let ws = null;
|
|
let wsRetryTimer = null;
|
|
|
|
function connectWS() {
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const url = `${proto}//${location.host}/ws/sensing`;
|
|
|
|
try {
|
|
ws = new WebSocket(url);
|
|
} catch { return; }
|
|
|
|
ws.onopen = () => {
|
|
state.wsConnected = true;
|
|
banner.style.display = 'none';
|
|
};
|
|
|
|
ws.onmessage = (ev) => {
|
|
try {
|
|
const msg = JSON.parse(ev.data);
|
|
if (msg.type === 'spatial_update' && msg.data) {
|
|
const d = msg.data;
|
|
if (d.grid_size) state.gridSize = d.grid_size;
|
|
if (d.bounds) state.bounds = d.bounds;
|
|
if (d.occupied_voxels) {
|
|
state.voxels = d.occupied_voxels;
|
|
updateVoxels();
|
|
}
|
|
if (d.clusters) {
|
|
state.clusters = d.clusters;
|
|
updateStickFigures();
|
|
}
|
|
updateInfoPanel(d);
|
|
}
|
|
if (msg.type === 'sensing' && msg.data) {
|
|
state.sensing = msg.data;
|
|
updateSensingInfo();
|
|
}
|
|
} catch { /* ignore parse errors */ }
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
state.wsConnected = false;
|
|
if (!state.apiAvailable) banner.style.display = 'block';
|
|
wsRetryTimer = setTimeout(connectWS, 5000);
|
|
};
|
|
|
|
ws.onerror = () => { ws.close(); };
|
|
}
|
|
|
|
connectWS();
|
|
|
|
// Poll fallbacks
|
|
setInterval(pollVolume, 2000);
|
|
setInterval(pollStatus, 5000);
|
|
setInterval(pollNodes, 10000);
|
|
setInterval(pollVitals, 5000);
|
|
setInterval(pollSensing, 5000);
|
|
setInterval(pollClusters, 2000);
|
|
|
|
// Initial fetch
|
|
pollVolume();
|
|
pollStatus();
|
|
pollNodes();
|
|
pollVitals();
|
|
pollSensing();
|
|
pollClusters();
|
|
|
|
// ── UI updates ───────────────────────────────────────────────────────
|
|
|
|
function updateInfoPanel(volData) {
|
|
if (volData) {
|
|
const gs = volData.grid_size || state.gridSize;
|
|
document.getElementById('valRes').textContent = `${gs[0]}x${gs[1]}x${gs[2]}`;
|
|
document.getElementById('valRecon').textContent = volData.reconstruction_count ?? '--';
|
|
document.getElementById('valResidual').textContent = volData.residual != null ? volData.residual.toFixed(4) : '--';
|
|
}
|
|
}
|
|
|
|
function updateStatusInfo() {
|
|
const s = state.status;
|
|
if (!s) return;
|
|
|
|
const el = document.getElementById('valStatus');
|
|
if (s.tomographer_ready) {
|
|
el.textContent = 'Ready';
|
|
el.className = 'info-value status-ready';
|
|
} else if (s.calibration_frames > 0) {
|
|
const pct = Math.min(100, Math.round((s.calibration_frames / 5000) * 100));
|
|
el.textContent = `Calibrating (${pct}%)`;
|
|
el.className = 'info-value status-calib';
|
|
} else {
|
|
el.textContent = 'Waiting for nodes...';
|
|
el.className = 'info-value status-wait';
|
|
}
|
|
|
|
document.getElementById('valNodes').textContent = `${s.active_nodes || 0}/${s.node_count || 0}`;
|
|
document.getElementById('valLinks').textContent = s.link_count ?? '--';
|
|
}
|
|
|
|
function updateVitalsInfo() {
|
|
const v = state.vitals;
|
|
if (!v) return;
|
|
document.getElementById('valBreath').textContent = v.breathing_rate_bpm != null ? v.breathing_rate_bpm.toFixed(1) + ' BPM' : '--';
|
|
document.getElementById('valHR').textContent = v.heart_rate_bpm != null ? v.heart_rate_bpm.toFixed(1) + ' BPM' : '--';
|
|
}
|
|
|
|
function updateSensingInfo() {
|
|
const s = state.sensing;
|
|
if (!s) return;
|
|
const persons = s.estimated_persons ?? (s.classification?.presence ? 1 : 0);
|
|
if (state.clusters.length > 0) {
|
|
const poses = state.clusters.map(c => c.pose_hint).join(', ');
|
|
document.getElementById('valPersons').textContent = `${state.clusters.length} (${poses})`;
|
|
} else {
|
|
document.getElementById('valPersons').textContent = persons;
|
|
}
|
|
}
|
|
|
|
// ── Toggle controls ──────────────────────────────────────────────────
|
|
|
|
document.querySelectorAll('.ctrl-btn[data-toggle]').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const key = btn.dataset.toggle;
|
|
state.visibility[key] = !state.visibility[key];
|
|
btn.classList.toggle('active', state.visibility[key]);
|
|
applyVisibility();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.ctrl-btn[data-action]').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
if (btn.dataset.action === 'resetView') {
|
|
camera.position.set(8, 6, 5);
|
|
controls.target.set(3, 1.5, 3);
|
|
controls.update();
|
|
} else if (btn.dataset.action === 'fullscreen') {
|
|
if (!document.fullscreenElement) {
|
|
document.documentElement.requestFullscreen().catch(() => {});
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
function applyVisibility() {
|
|
voxelGroup.visible = state.visibility.voxels;
|
|
nodeGroup.visible = state.visibility.nodes;
|
|
linkGroup.visible = state.visibility.links;
|
|
labelGroup.visible = state.visibility.labels;
|
|
presenceGroup.visible = state.visibility.presence;
|
|
}
|
|
|
|
// ── FPS counter ──────────────────────────────────────────────────────
|
|
|
|
let frameCount = 0;
|
|
let lastFpsTime = performance.now();
|
|
const fpsEl = document.getElementById('fpsCounter');
|
|
|
|
function updateFPS() {
|
|
frameCount++;
|
|
const now = performance.now();
|
|
if (now - lastFpsTime >= 1000) {
|
|
const fps = Math.round(frameCount * 1000 / (now - lastFpsTime));
|
|
fpsEl.textContent = fps + ' FPS';
|
|
frameCount = 0;
|
|
lastFpsTime = now;
|
|
}
|
|
}
|
|
|
|
// ── Animation loop ───────────────────────────────────────────────────
|
|
|
|
const clock = new THREE.Clock();
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
const t = clock.getElapsedTime();
|
|
const dt = clock.getDelta();
|
|
|
|
controls.update();
|
|
|
|
// Node pulse animation
|
|
nodeGroup.children.forEach(mesh => {
|
|
if (mesh.userData.online) {
|
|
const phase = mesh.userData.pulsePhase || 0;
|
|
const s = 1.0 + 0.15 * Math.sin(t * 3.0 + phase);
|
|
mesh.scale.setScalar(s);
|
|
}
|
|
});
|
|
|
|
// Stick figure animation: position lerp, breathing, activity motion, pose transitions
|
|
for (const [id, fig] of activeFigures) {
|
|
// Position lerp
|
|
for (let i = 0; i < 3; i++) {
|
|
fig.currentCentroid[i] += (fig.targetCentroid[i] - fig.currentCentroid[i]) * 0.1;
|
|
}
|
|
|
|
// Pose blend (lerp keypoints over ~1s at 30fps)
|
|
if (fig.poseBlend < 1.0) {
|
|
fig.poseBlend = Math.min(1.0, fig.poseBlend + 0.03);
|
|
const b = fig.poseBlend;
|
|
for (let k = 0; k < fig.currentKeypoints.length; k++) {
|
|
for (let d = 0; d < 3; d++) {
|
|
fig.currentKeypoints[k][d] = fig.prevKeypoints[k][d] * (1 - b) + fig.targetKeypoints[k][d] * b;
|
|
}
|
|
}
|
|
rebuildFigureGeometry(fig);
|
|
} else {
|
|
// Snap to target keypoints if not already there
|
|
let needsUpdate = false;
|
|
for (let k = 0; k < fig.currentKeypoints.length; k++) {
|
|
for (let d = 0; d < 3; d++) {
|
|
if (fig.currentKeypoints[k][d] !== fig.targetKeypoints[k][d]) {
|
|
fig.currentKeypoints[k][d] = fig.targetKeypoints[k][d];
|
|
needsUpdate = true;
|
|
}
|
|
}
|
|
}
|
|
if (needsUpdate) rebuildFigureGeometry(fig);
|
|
}
|
|
|
|
// Breathing
|
|
const brBpm = state.vitals?.breathing_rate_bpm || 15;
|
|
const breathScale = 1.0 + 0.02 * Math.sin(t * (brBpm / 60) * Math.PI * 2 + fig.phase);
|
|
|
|
// Activity-based motion
|
|
const activity = state.sensing?.classification?.motion_level || 'absent';
|
|
let activityOscX = 0, activityOscZ = 0;
|
|
if (activity === 'moderate' || activity === 'present_gentle') {
|
|
activityOscX = 0.03 * Math.sin(t * 1.0 + fig.phase);
|
|
activityOscZ = 0.02 * Math.cos(t * 0.7 + fig.phase);
|
|
} else if (activity === 'active') {
|
|
activityOscX = 0.08 * Math.sin(t * 2.0 + fig.phase);
|
|
activityOscZ = 0.05 * Math.cos(t * 1.4 + fig.phase);
|
|
} else {
|
|
// Idle sway
|
|
activityOscX = 0.01 * Math.sin(t * 0.5 + fig.phase);
|
|
}
|
|
|
|
const cx = fig.currentCentroid[0] + activityOscX;
|
|
const cy = fig.currentCentroid[1] + activityOscZ;
|
|
const cz = fig.currentCentroid[2];
|
|
const baseZ = cz - fig.zExtent / 2;
|
|
|
|
fig.group.position.copy(apiToWorld(cx, cy, baseZ));
|
|
fig.group.scale.set(1, breathScale, 1);
|
|
|
|
// Idle sway rotation
|
|
fig.group.rotation.y = Math.sin(t * 0.5 + fig.phase) * 0.05;
|
|
|
|
// Opacity fade for disappearing figures
|
|
if (fig.quality < 1.0) {
|
|
fig.group.traverse(child => {
|
|
if (child.material) child.material.opacity = Math.max(0, fig.quality);
|
|
});
|
|
}
|
|
}
|
|
|
|
renderer.render(scene, camera);
|
|
updateFPS();
|
|
}
|
|
|
|
animate();
|
|
|
|
// ── Resize ───────────────────────────────────────────────────────────
|
|
|
|
window.addEventListener('resize', () => {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|