spaxel/dashboard/js/viz3d.js
jedarden ff3428fee6 feat: robust WebSocket reconnection with backoff, extrapolation, and visual states
Implements exponential backoff (1s→10s cap) with ±500ms jitter,
blob position extrapolation during disconnects (capped at 2s),
three visual states (silent <5s, dimming 5-30s, modal >30s),
and automatic scene restoration on reconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 12:34:35 -04:00

2176 lines
78 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Spaxel Viz3D Phase 3 spatial visualization
*
* Handles: room bounds, floor-plan texture, humanoid SkinnedMesh + AnimationMixer,
* node meshes, link lines, blob trails, vertical pillar anchors, view presets.
*
* Depends on Three.js r128 being loaded before this script.
*/
const Viz3D = (function () {
'use strict';
// ── module state ──────────────────────────────────────────────────────────
let _scene, _camera, _controls, _clock, _renderer;
let _room = null;
let _roomObjs = { floor: null, ceiling: null, walls: [], edges: null };
let _nodeMeshes = new Map(); // mac → THREE.Mesh
let _linkLines = new Map(); // id → THREE.Line
let _activeLinks = new Map(); // id → { node_mac, peer_mac, health_score }
let _blobs3D = new Map(); // blobId → blobObj
let _linkHealth = new Map(); // id → { score, details, last_updated }
let _mixers = [];
let _floorTex = null;
let _followId = null;
// ── blob interaction state ────────────────────────────────────────────────
let _raycaster = new THREE.Raycaster();
let _mouse = new THREE.Vector2();
let _hoveredBlob = null;
let _feedbackTooltip = null;
let _renderer = null;
// Ghost node for repositioning advice
let _ghostNode = null; // THREE.Mesh (translucent)
let _ghostLine = null; // THREE.Line (dashed, from original to ghost)
let _ghostNodeMAC = null; // MAC of the node being moved
const BLOB_COLORS = [0xef5350, 0x66bb6a, 0x42a5f5, 0xffa726, 0xab47bc, 0x26c6da];
const TRAIL_COLORS = [0xff8a80, 0xa5d6a7, 0x90caf9, 0xffcc80, 0xce93d8, 0x80deea];
// ── init / tick ───────────────────────────────────────────────────────────
function init(scene, camera, controls, renderer) {
_scene = scene;
_camera = camera;
_controls = controls;
_clock = new THREE.Clock();
// Initialize blob interaction if renderer provided
if (renderer) {
initBlobInteraction(renderer);
_addBlobFeedbackStyles();
}
}
function update() {
const dt = _clock.getDelta();
for (let i = 0; i < _mixers.length; i++) _mixers[i].update(dt);
if (_followId !== null) {
const b = _blobs3D.get(_followId);
if (b) {
const p = b.group.position;
_camera.position.lerp(new THREE.Vector3(p.x + 1.5, 1.8, p.z + 3.5), 0.07);
_controls.target.lerp(new THREE.Vector3(p.x, 1.3, p.z), 0.07);
_controls.update();
}
}
// Update ghost line if node moved
_updateGhostLine();
// Update flow arrow animation
updateFlowAnimation(dt);
// Update anomaly zone pulse
updateAnomalyPulse(dt);
}
// ── room bounds ───────────────────────────────────────────────────────────
function clearRoom() {
const r = _roomObjs;
if (r.floor) _scene.remove(r.floor);
if (r.ceiling) _scene.remove(r.ceiling);
if (r.edges) _scene.remove(r.edges);
r.walls.forEach(w => _scene.remove(w));
_roomObjs = { floor: null, ceiling: null, walls: [], edges: null };
}
function applyRoom(cfg) {
clearRoom();
_room = cfg;
const w = cfg.width, d = cfg.depth, h = cfg.height;
const ox = cfg.origin_x || 0, oz = cfg.origin_z || 0;
const cx = ox + w / 2, cz = oz + d / 2;
// floor
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(w, d),
new THREE.MeshLambertMaterial({ color: 0x1e2a3a, map: _floorTex, side: THREE.FrontSide })
);
floor.rotation.x = -Math.PI / 2;
floor.position.set(cx, 0.001, cz);
_scene.add(floor);
_roomObjs.floor = floor;
// ceiling (dim, transparent)
const ceil = new THREE.Mesh(
new THREE.PlaneGeometry(w, d),
new THREE.MeshLambertMaterial({ color: 0x1a2030, transparent: true, opacity: 0.25, side: THREE.BackSide })
);
ceil.rotation.x = Math.PI / 2;
ceil.position.set(cx, h, cz);
_scene.add(ceil);
_roomObjs.ceiling = ceil;
// walls (semi-transparent, double-sided)
const wallMat = new THREE.MeshLambertMaterial({ color: 0x243040, transparent: true, opacity: 0.13, side: THREE.DoubleSide });
[
{ pw: w, ry: 0, px: cx, py: h / 2, pz: oz },
{ pw: w, ry: Math.PI, px: cx, py: h / 2, pz: oz + d },
{ pw: d, ry: Math.PI / 2, px: ox, py: h / 2, pz: cz },
{ pw: d, ry:-Math.PI / 2, px: ox + w, py: h / 2, pz: cz },
].forEach(({ pw, ry, px, py, pz }) => {
const m = new THREE.Mesh(new THREE.PlaneGeometry(pw, h), wallMat);
m.rotation.y = ry;
m.position.set(px, py, pz);
_scene.add(m);
_roomObjs.walls.push(m);
});
// wireframe edges (floor rect + ceiling rect + 4 verticals)
const e = [ox,oz, ox+w,oz, ox+w,oz+d, ox,oz+d, ox,oz]; // perimeter loop
const verts = [];
for (let i = 0; i < e.length - 1; i += 2) {
verts.push(e[i],0,e[i+1], e[i+2],0,e[i+3]);
verts.push(e[i],h,e[i+1], e[i+2],h,e[i+3]);
}
[[ox,oz],[ox+w,oz],[ox+w,oz+d],[ox,oz+d]].forEach(([ex, ez]) => {
verts.push(ex,0,ez, ex,h,ez);
});
const edgeGeo = new THREE.BufferGeometry();
edgeGeo.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3));
const edges = new THREE.LineSegments(edgeGeo, new THREE.LineBasicMaterial({ color: 0x556677, transparent: true, opacity: 0.55 }));
_scene.add(edges);
_roomObjs.edges = edges;
}
// ── floor plan texture ────────────────────────────────────────────────────
function uploadFloorPlan(file) {
const url = URL.createObjectURL(file);
new THREE.TextureLoader().load(url, function (tex) {
_floorTex = tex;
if (_roomObjs.floor) {
_roomObjs.floor.material.map = tex;
_roomObjs.floor.material.needsUpdate = true;
}
URL.revokeObjectURL(url);
});
}
// ── node meshes ───────────────────────────────────────────────────────────
function applyNodeRegistry(nodes) {
const incoming = new Set(nodes.map(n => n.mac));
_nodeMeshes.forEach((m, mac) => {
if (!incoming.has(mac)) { _scene.remove(m); _nodeMeshes.delete(mac); }
});
nodes.forEach(n => {
let m = _nodeMeshes.get(n.mac);
if (!m) {
// Check if this is a virtual router AP node
const isRouterAP = n.virtual && n.node_type === 'ap';
if (isRouterAP) {
// Create a router icon: box with 4 antennas
m = _createRouterMesh();
} else {
// Standard node: Octahedron
const col = n.virtual ? 0x80cbc4 : 0x4fc3f7;
m = new THREE.Mesh(
new THREE.OctahedronGeometry(0.12, 0),
new THREE.MeshPhongMaterial({ color: col, emissive: col, emissiveIntensity: 0.35, shininess: 60 })
);
}
_scene.add(m);
_nodeMeshes.set(n.mac, m);
}
m.position.set(n.pos_x, n.pos_y, n.pos_z);
});
_rebuildLinkLines();
}
/**
* Creates a router icon mesh (box with antennas)
* @returns {THREE.Group} Group containing router geometry
*/
function _createRouterMesh() {
const routerGroup = new THREE.Group();
// Router body (horizontal box)
const bodyGeo = new THREE.BoxGeometry(0.16, 0.04, 0.1);
const routerMat = new THREE.MeshPhongMaterial({
color: 0x80cbc4, // Teal for virtual AP
emissive: 0x80cbc4,
emissiveIntensity: 0.3,
shininess: 80
});
const body = new THREE.Mesh(bodyGeo, routerMat);
routerGroup.add(body);
// 4 antennas (vertical cylinders at corners)
const antennaGeo = new THREE.CylinderGeometry(0.008, 0.008, 0.12, 8);
const antennaMat = new THREE.MeshPhongMaterial({
color: 0x4dd0e1,
emissive: 0x4dd0e1,
emissiveIntensity: 0.2
});
// Antenna positions (relative to body center)
const antennaPositions = [
[-0.06, 0.06, 0.03],
[0.06, 0.06, 0.03],
[-0.06, 0.06, -0.03],
[0.06, 0.06, -0.03]
];
antennaPositions.forEach(pos => {
const antenna = new THREE.Mesh(antennaGeo, antennaMat);
antenna.position.set(pos[0], pos[1], pos[2]);
routerGroup.add(antenna);
});
// Add LED indicator (small glowing sphere on top)
const ledGeo = new THREE.SphereGeometry(0.012, 8, 8);
const ledMat = new THREE.MeshBasicMaterial({ color: 0x00ff00 }); // Green LED
const led = new THREE.Mesh(ledGeo, ledMat);
led.position.set(0, 0.03, 0);
routerGroup.add(led);
return routerGroup;
}
function applyLinks(links) {
_activeLinks.clear();
(links || []).forEach(l => {
const id = l.id || `${l.node_mac}:${l.peer_mac}`;
_activeLinks.set(id, l);
});
_rebuildLinkLines();
}
/**
* Get health color for a link based on health score.
* Green (#22c55e at health=1.0) → Yellow (#eab308 at health=0.5) → Red (#ef4444 at health=0)
* @param {number} health - Health score in [0, 1]
* @returns {number} Three.js color hex value
*/
function _getHealthColor(health) {
// Interpolate from red (0) through yellow (0.5) to green (1)
var r, g, b;
if (health < 0.5) {
// Red to yellow
var t = health * 2; // 0-0.5 maps to 0-1
r = 0.94; // 0xef/255
g = 0.31 + t * 0.61; // 0x4f → 0xeab3 (approx)
b = 0.14 + t * 0.06;
} else {
// Yellow to green
var t = (health - 0.5) * 2; // 0.5-1 maps to 0-1
r = 0.92 - t * 0.78; // 0xeab3 → 0x22
g = 0.69 + t * 0.09; // stays mostly yellow-green
b = 0.08 + t * 0.28; // 0x08 → 0x5e
}
return (Math.round(r * 255) << 16) | (Math.round(g * 255) << 8) | Math.round(b * 255);
}
/**
* Get link line thickness based on health score.
* health > 0.7 → 2px, health 0.4-0.7 → 1px, health < 0.4 → 0.5px
* @param {number} health - Health score in [0, 1]
* @returns {number} Line thickness
*/
function _getHealthThickness(health) {
if (health > 0.7) return 2;
if (health >= 0.4) return 1;
return 0.5;
}
function _rebuildLinkLines() {
_linkLines.forEach(l => _scene.remove(l));
_linkLines.clear();
_activeLinks.forEach((link, id) => {
const a = _nodeMeshes.get(link.node_mac);
const b = _nodeMeshes.get(link.peer_mac);
if (!a || !b) return;
// Get health score from stored health data or link object
var healthData = _linkHealth.get(id);
var healthScore = healthData ? healthData.score : (link.health_score !== undefined ? link.health_score : 0.5);
var healthColor = _getHealthColor(healthScore);
var thickness = _getHealthThickness(healthScore);
// Scale opacity by health for lower health links
var opacity = 0.3 + healthScore * 0.5;
const geo = new THREE.BufferGeometry().setFromPoints([a.position.clone(), b.position.clone()]);
const line = new THREE.Line(geo, new THREE.LineBasicMaterial({
color: healthColor,
transparent: true,
opacity: opacity,
linewidth: thickness // Note: linewidth > 1 only works on some platforms
}));
_scene.add(line);
_linkLines.set(id, line);
});
}
/**
* Update link health scores from API response.
* @param {Array} links - Array of link objects with health_score and health_details
*/
function updateLinkHealth(links) {
if (!links) return;
links.forEach(function(link) {
var id = link.link_id || (link.node_mac + ':' + link.peer_mac);
_linkHealth.set(id, {
score: link.health_score !== undefined ? link.health_score : 0.5,
details: link.health_details || {},
last_updated: link.last_updated
});
// Also update _activeLinks with health score
if (_activeLinks.has(id)) {
var existing = _activeLinks.get(id);
existing.health_score = link.health_score;
existing.health_details = link.health_details;
}
});
_rebuildLinkLines();
}
/**
* Get health data for a specific link.
* @param {string} linkID - Link identifier
* @returns {Object|null} Health data object or null
*/
function getLinkHealth(linkID) {
return _linkHealth.get(linkID) || null;
}
/**
* Get all current health scores.
* @returns {Map} Map of linkID → health data
*/
function getAllLinkHealth() {
return new Map(_linkHealth);
}
// ── humanoid SkinnedMesh factory ──────────────────────────────────────────
//
// Bone index constants
const BI = { ROOT:0, PELVIS:1, SPINE:2, CHEST:3, HEAD:4,
LS:5, LE:6, RS:7, RE:8,
LH:9, LK:10, RH:11, RK:12 };
function _buildBones() {
const bones = [];
function b(name, x, y, z) {
const bn = new THREE.Bone();
bn.name = name;
bn.position.set(x, y, z);
bones.push(bn);
return bn;
}
// local positions relative to parent
const root = b('root', 0, 0, 0);
const pelvis = b('pelvis', 0, 0.9, 0);
const spine = b('spine', 0, 0.25, 0); // world y ≈ 1.15
const chest = b('chest', 0, 0.25, 0); // world y ≈ 1.4
const head = b('head', 0, 0.22, 0); // world y ≈ 1.62
const ls = b('l_shoulder', -0.18, 0, 0); // world (-0.18, 1.4, 0)
const le = b('l_elbow', -0.25, 0, 0); // world (-0.43, 1.4, 0)
const rs = b('r_shoulder', 0.18, 0, 0);
const re = b('r_elbow', 0.25, 0, 0);
const lh = b('l_hip', -0.1, 0, 0); // world (-0.1, 0.9, 0)
const lk = b('l_knee', 0, -0.44, 0); // world (-0.1, 0.46, 0)
const rh = b('r_hip', 0.1, 0, 0);
const rk = b('r_knee', 0, -0.44, 0);
root.add(pelvis);
pelvis.add(spine);
spine.add(chest);
chest.add(head);
chest.add(ls); ls.add(le);
chest.add(rs); rs.add(re);
pelvis.add(lh); lh.add(lk);
pelvis.add(rh); rh.add(rk);
return bones;
}
// Merge an array of {geo, boneIdx} into one BufferGeometry with skinning attrs.
function _mergeWithSkin(parts) {
let totalVerts = 0;
const indexArrays = [];
parts.forEach(({ geo }) => {
if (!geo.index) geo = geo.toNonIndexed();
totalVerts += geo.attributes.position.count;
});
const pos = new Float32Array(totalVerts * 3);
const nrm = new Float32Array(totalVerts * 3);
const si = new Float32Array(totalVerts * 4); // skinIndex
const sw = new Float32Array(totalVerts * 4); // skinWeight
const idxArr = [];
let vOff = 0;
parts.forEach(({ geo: g, boneIdx }) => {
if (!g.index) g = g.toNonIndexed();
const p = g.attributes.position.array;
const n = g.attributes.normal ? g.attributes.normal.array : null;
const cnt = g.attributes.position.count;
for (let i = 0; i < cnt; i++) {
pos[(vOff+i)*3+0] = p[i*3+0];
pos[(vOff+i)*3+1] = p[i*3+1];
pos[(vOff+i)*3+2] = p[i*3+2];
if (n) { nrm[(vOff+i)*3+0]=n[i*3+0]; nrm[(vOff+i)*3+1]=n[i*3+1]; nrm[(vOff+i)*3+2]=n[i*3+2]; }
si[(vOff+i)*4] = boneIdx;
sw[(vOff+i)*4] = 1.0;
}
if (g.index) {
const ia = g.index.array;
for (let i = 0; i < ia.length; i++) idxArr.push(ia[i] + vOff);
} else {
for (let i = 0; i < cnt; i++) idxArr.push(vOff + i);
}
vOff += cnt;
});
const merged = new THREE.BufferGeometry();
merged.setAttribute('position', new THREE.BufferAttribute(pos, 3));
merged.setAttribute('normal', new THREE.BufferAttribute(nrm, 3));
merged.setAttribute('skinIndex', new THREE.BufferAttribute(new Uint16Array(si), 4));
merged.setAttribute('skinWeight',new THREE.BufferAttribute(sw, 4));
merged.setIndex(idxArr);
return merged;
}
function _buildBodyGeometry() {
const parts = [];
function cyl(rT, rB, h, segs, boneIdx, tx, ty, tz, rx, ry, rz) {
const g = new THREE.CylinderGeometry(rT, rB, h, segs);
const m = new THREE.Matrix4().compose(
new THREE.Vector3(tx, ty, tz),
new THREE.Quaternion().setFromEuler(new THREE.Euler(rx||0, ry||0, rz||0)),
new THREE.Vector3(1,1,1)
);
g.applyMatrix4(m);
parts.push({ geo: g, boneIdx });
}
function sph(r, ws, hs, boneIdx, tx, ty, tz) {
const g = new THREE.SphereGeometry(r, ws, hs);
g.translate(tx, ty, tz);
parts.push({ geo: g, boneIdx });
}
// torso (spine)
cyl(0.13, 0.10, 0.48, 8, BI.SPINE, 0, 1.16, 0);
// shoulder bar (chest)
cyl(0.05, 0.05, 0.34, 6, BI.CHEST, 0, 1.40, 0, 0, 0, Math.PI/2);
// neck + head
cyl(0.05, 0.055,0.12, 6, BI.HEAD, 0, 1.58, 0);
sph(0.11, 8, 6, BI.HEAD, 0, 1.72, 0);
// left upper arm cylinder along X
cyl(0.04, 0.04, 0.22, 6, BI.LS, -0.30, 1.40, 0, 0, 0, Math.PI/2);
// left forearm
cyl(0.035,0.03, 0.20, 6, BI.LE, -0.54, 1.40, 0, 0, 0, Math.PI/2);
// right upper arm cylinder along +X
cyl(0.04, 0.04, 0.22, 6, BI.RS, 0.30, 1.40, 0, 0, 0,-Math.PI/2);
// right forearm
cyl(0.035,0.03, 0.20, 6, BI.RE, 0.54, 1.40, 0, 0, 0,-Math.PI/2);
// left upper leg
cyl(0.065,0.055,0.42, 7, BI.LH, -0.10, 0.68, 0);
// left lower leg
cyl(0.05, 0.04, 0.42, 7, BI.LK, -0.10, 0.25, 0);
// right upper leg
cyl(0.065,0.055,0.42, 7, BI.RH, 0.10, 0.68, 0);
// right lower leg
cyl(0.05, 0.04, 0.42, 7, BI.RK, 0.10, 0.25, 0);
return _mergeWithSkin(parts);
}
function _qFlat(euler) {
const q = new THREE.Quaternion().setFromEuler(
new THREE.Euler(euler[0], euler[1], euler[2])
);
return [q.x, q.y, q.z, q.w];
}
function _buildAnimClips() {
function qt(name, times, eulerFrames) {
const vals = [];
eulerFrames.forEach(e => vals.push(..._qFlat(e)));
return new THREE.QuaternionKeyframeTrack(`${name}.quaternion`, times, vals);
}
function staticTrack(name, euler) {
const q = _qFlat(euler);
return new THREE.QuaternionKeyframeTrack(`${name}.quaternion`, [0, 1], [...q, ...q]);
}
function identTrack(name) {
return staticTrack(name, [0,0,0]);
}
// ── standing: identity pose ──
const standTracks = ['l_hip','r_hip','l_knee','r_knee','l_shoulder','r_shoulder']
.map(identTrack);
const standing = new THREE.AnimationClip('standing', 1, standTracks);
// ── walking: 1.2 s loop, 5 keyframes ──
const wt = [0, 0.3, 0.6, 0.9, 1.2];
function walkSwing(name, a0, a1) {
return qt(name, wt, [
[a0,0,0], [a1,0,0], [a0,0,0], [a1,0,0], [a0,0,0]
]);
}
const walking = new THREE.AnimationClip('walking', 1.2, [
walkSwing('l_hip', -0.50, 0.50),
walkSwing('r_hip', 0.50, -0.50),
walkSwing('l_knee', 0.00, 0.45),
walkSwing('r_knee', 0.45, 0.00),
walkSwing('l_shoulder', 0.28, -0.28),
walkSwing('r_shoulder',-0.28, 0.28),
]);
// ── seated: hips flexed, knees bent ──
const seated = new THREE.AnimationClip('seated', 1, [
staticTrack('pelvis', [-Math.PI/2, 0, 0]),
staticTrack('l_hip', [ Math.PI/2, 0, 0]),
staticTrack('r_hip', [ Math.PI/2, 0, 0]),
staticTrack('l_knee', [-Math.PI/2, 0, 0]),
staticTrack('r_knee', [-Math.PI/2, 0, 0]),
]);
// ── lying: whole figure horizontal ──
const lying = new THREE.AnimationClip('lying', 1, [
staticTrack('root', [-Math.PI/2, 0, 0]),
]);
return { standing, walking, seated, lying };
}
function _buildHumanoid(color) {
const bones = _buildBones();
const geo = _buildBodyGeometry();
const mat = new THREE.MeshPhongMaterial({
color: color || 0x4fc3f7,
skinning: true,
shininess: 40,
});
const mesh = new THREE.SkinnedMesh(geo, mat);
mesh.add(bones[0]);
mesh.bind(new THREE.Skeleton(bones));
const mixer = new THREE.AnimationMixer(mesh);
const clips = _buildAnimClips();
const actions = {};
Object.entries(clips).forEach(([name, clip]) => {
const a = mixer.clipAction(clip);
a.setLoop(THREE.LoopRepeat, Infinity);
actions[name] = a;
});
actions.standing.play();
return { mesh, mixer, actions, posture: 'standing' };
}
function _setPosture(h, posture) {
if (h.posture === posture) return;
const from = h.actions[h.posture];
const to = h.actions[posture];
if (from) from.fadeOut(0.35);
if (to) to.reset().fadeIn(0.35).play();
h.posture = posture;
}
// ── blob management ───────────────────────────────────────────────────────
function _createBlobObj(id) {
const ci = id % BLOB_COLORS.length;
const color = BLOB_COLORS[ci];
const group = new THREE.Group();
group.userData.blobId = id; // Store blob ID for interaction
_scene.add(group);
const humanoid = _buildHumanoid(color);
group.add(humanoid.mesh);
_mixers.push(humanoid.mixer);
// footprint trail (max 60 pts, Y=floor)
const trailPos = new Float32Array(60 * 3);
const trailGeo = new THREE.BufferGeometry();
trailGeo.setAttribute('position', new THREE.BufferAttribute(trailPos, 3));
trailGeo.setDrawRange(0, 0);
const trail = new THREE.Line(
trailGeo,
new THREE.LineBasicMaterial({ color: TRAIL_COLORS[ci % TRAIL_COLORS.length], transparent: true, opacity: 0.5 })
);
trail.frustumCulled = false;
_scene.add(trail);
// vertical pillar anchor
const pillarGeo = new THREE.BufferGeometry();
pillarGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array([0,0,0, 0,2.5,0]), 3));
const pillar = new THREE.Line(
pillarGeo,
new THREE.LineBasicMaterial({ color: 0x445566, transparent: true, opacity: 0.3 })
);
_scene.add(pillar);
return { group, humanoid, trail, pillar, blobId: id };
}
function _removeBlobObj(id, obj) {
_scene.remove(obj.group);
_scene.remove(obj.trail);
_scene.remove(obj.pillar);
const idx = _mixers.indexOf(obj.humanoid.mixer);
if (idx !== -1) _mixers.splice(idx, 1);
_blobs3D.delete(id);
if (_followId === id) _followId = null;
}
function _updateTrail(obj, trailData) {
if (!trailData || trailData.length === 0) return;
const arr = obj.trail.geometry.attributes.position.array;
const cnt = Math.min(trailData.length, 60);
for (let i = 0; i < cnt; i++) {
arr[i*3+0] = trailData[i][0];
arr[i*3+1] = 0.02;
arr[i*3+2] = trailData[i][1];
}
obj.trail.geometry.attributes.position.needsUpdate = true;
obj.trail.geometry.setDrawRange(0, cnt);
}
function _updatePillar(obj, x, z, height) {
const a = obj.pillar.geometry.attributes.position.array;
a[0]=x; a[1]=0.05; a[2]=z;
a[3]=x; a[4]=height-0.05; a[5]=z;
obj.pillar.geometry.attributes.position.needsUpdate = true;
}
function applyLocUpdate(blobs) {
const seen = new Set();
const now = Date.now();
blobs.forEach(b => {
seen.add(b.id);
let obj = _blobs3D.get(b.id);
if (!obj) {
obj = _createBlobObj(b.id);
obj.createdAt = now;
_blobs3D.set(b.id, obj);
}
obj.group.position.set(b.x, 0, b.z);
obj.lastPosition = { x: b.x, z: b.z };
obj.lastVelocity = { vx: b.vx || 0, vz: b.vz || 0 };
const speed = Math.sqrt(b.vx*b.vx + b.vz*b.vz);
_setPosture(obj.humanoid, speed > 0.25 ? 'walking' : 'standing');
if (speed > 0.25) {
obj.humanoid.actions.walking.timeScale = Math.min(speed * 1.8, 2.5);
obj.group.rotation.y = Math.atan2(b.vx, b.vz);
}
_updateTrail(obj, b.trail);
if (_room) _updatePillar(obj, b.x, b.z, _room.height);
});
_blobs3D.forEach((obj, id) => {
if (!seen.has(id)) _removeBlobObj(id, obj);
});
}
// ── identity label rendering ────────────────────────────────────────────────
let _identityLabels = new Map(); // blobId → THREE.Sprite (text label)
let _bleOnlyTracks = new Map(); // personID → { group, pillar, circle }
/**
* Create a text sprite with the given text and color.
* @param {string} text - Label text
* @param {string} color - CSS color string (e.g., '#3b82f6')
* @returns {THREE.Sprite}
*/
function _createTextSprite(text, color) {
var canvas = document.createElement('canvas');
var ctx = canvas.getContext('2d');
canvas.width = 256;
canvas.height = 64;
// Draw background with rounded corners
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.beginPath();
ctx.roundRect(4, 4, canvas.width - 8, canvas.height - 8, 8);
ctx.fill();
// Draw border in person color
ctx.strokeStyle = color || '#4fc3f7';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.roundRect(4, 4, canvas.width - 8, canvas.height - 8, 8);
ctx.stroke();
// Draw text
ctx.fillStyle = color || '#ffffff';
ctx.font = 'bold 28px Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
var texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
var material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false
});
var sprite = new THREE.Sprite(material);
sprite.scale.set(1.2, 0.3, 1);
sprite.position.set(0, 2.0, 0); // Above humanoid head
return sprite;
}
/**
* Create a BLE-only placeholder track visualization.
* These are shown when a BLE device is heard but no CSI blob is nearby.
* @param {Object} match - IdentityMatch with triangulation position
* @returns {Object} Three.js objects { group, pillar, circle }
*/
function _createBLEOnlyTrack(match) {
var group = new THREE.Group();
group.userData.personId = match.person_id;
group.userData.isBLEOnly = true;
// Dashed circle on floor to indicate BLE-only position
var circleGeo = new THREE.RingGeometry(0.25, 0.35, 32);
var circleMat = new THREE.MeshBasicMaterial({
color: match.person_color ? parseInt(match.person_color.replace('#', '0x')) : 0x4fc3f7,
transparent: true,
opacity: 0.5,
side: THREE.DoubleSide
});
var circle = new THREE.Mesh(circleGeo, circleMat);
circle.rotation.x = -Math.PI / 2;
circle.position.y = 0.02;
group.add(circle);
// Vertical dashed pillar
var pillarGeo = new THREE.BufferGeometry();
pillarGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array([0, 0, 0, 0, 2.0, 0]), 3));
var pillarMat = new THREE.LineDashedMaterial({
color: 0x888888,
dashSize: 0.1,
gapSize: 0.05,
transparent: true,
opacity: 0.4
});
var pillar = new THREE.Line(pillarGeo, pillarMat);
pillar.computeLineDistances();
group.add(pillar);
// Position from triangulation
var pos = match.triangulation_pos || { x: 0, y: 0, z: 0 };
group.position.set(pos.x, 0, pos.z);
// Add identity label
if (match.person_name) {
var label = _createTextSprite(match.person_name, match.person_color);
label.position.set(0, 1.2, 0);
group.add(label);
group.userData.label = label;
}
_scene.add(group);
return { group: group, pillar: pillar, circle: circle };
}
/**
* Update identity labels on tracked blobs.
* Called from BLEPanel when matches are updated.
* @param {Array} matches - Array of IdentityMatch objects
*/
function updateIdentities(matches) {
if (!matches) matches = [];
var matchesByBlobId = new Map();
matches.forEach(function(m) {
if (m.blob_id > 0) {
matchesByBlobId.set(m.blob_id, m);
}
});
// Update or create identity labels on existing blobs
_blobs3D.forEach(function(obj, blobId) {
var match = matchesByBlobId.get(blobId);
// Remove existing label if any
if (obj.identityLabel) {
obj.group.remove(obj.identityLabel);
obj.identityLabel = null;
}
if (match && match.person_name && match.confidence >= 0.6) {
// Create new label
var label = _createTextSprite(match.person_name, match.person_color);
label.position.set(0, 2.0, 0);
obj.group.add(label);
obj.identityLabel = label;
// Update humanoid color if available
if (match.person_color && obj.humanoid && obj.humanoid.mesh) {
var color = parseInt(match.person_color.replace('#', '0x'));
obj.humanoid.mesh.material.color.setHex(color);
obj.humanoid.mesh.material.emissive.setHex(color);
obj.humanoid.mesh.material.emissiveIntensity = 0.15;
}
// Store identity info
obj.identity = match;
} else {
// Reset to default color
var ci = blobId % BLOB_COLORS.length;
if (obj.humanoid && obj.humanoid.mesh) {
obj.humanoid.mesh.material.color.setHex(BLOB_COLORS[ci]);
obj.humanoid.mesh.material.emissive = new THREE.Color(BLOB_COLORS[ci]);
obj.humanoid.mesh.material.emissiveIntensity = 0;
}
obj.identity = null;
}
});
// Handle BLE-only tracks (devices heard but no CSI blob nearby)
var seenBLEOnly = new Set();
matches.forEach(function(match) {
if (match.is_ble_only && match.person_id) {
seenBLEOnly.add(match.person_id);
var existing = _bleOnlyTracks.get(match.person_id);
var pos = match.triangulation_pos || { x: 0, y: 0, z: 0 };
if (existing) {
// Update position
existing.group.position.set(pos.x, 0, pos.z);
existing.group.visible = true;
} else {
// Create new BLE-only track
var track = _createBLEOnlyTrack(match);
_bleOnlyTracks.set(match.person_id, track);
}
}
});
// Hide BLE-only tracks not in current matches
_bleOnlyTracks.forEach(function(track, personId) {
if (!seenBLEOnly.has(personId)) {
track.group.visible = false;
}
});
}
/**
* Get identity info for a blob.
* @param {number} blobId
* @returns {Object|null} Identity match or null
*/
function getBlobIdentity(blobId) {
var obj = _blobs3D.get(blobId);
return obj ? obj.identity : null;
}
/**
* Clear all identity labels.
*/
function clearIdentities() {
_identityLabels.forEach(function(label) {
if (label.parent) label.parent.remove(label);
});
_identityLabels.clear();
_bleOnlyTracks.forEach(function(track) {
_scene.remove(track.group);
});
_bleOnlyTracks.clear();
_blobs3D.forEach(function(obj) {
if (obj.identityLabel) {
obj.group.remove(obj.identityLabel);
obj.identityLabel = null;
}
obj.identity = null;
});
}
// ── blob interaction (feedback buttons) ────────────────────────────────────
/**
* Initialize blob interaction system.
* @param {THREE.WebGLRenderer} renderer - The Three.js renderer
*/
function initBlobInteraction(renderer) {
_renderer = renderer;
// Create feedback tooltip element
_feedbackTooltip = document.createElement('div');
_feedbackTooltip.className = 'blob-feedback-tooltip';
_feedbackTooltip.style.display = 'none';
document.body.appendChild(_feedbackTooltip);
// Add mouse move listener
var canvas = renderer.domElement;
canvas.addEventListener('mousemove', _onBlobMouseMove);
canvas.addEventListener('mouseleave', _hideBlobFeedbackTooltip);
canvas.addEventListener('contextmenu', _onBlobContextMenu);
// Close context menu on click elsewhere
document.addEventListener('click', _hideBlobContextMenu);
}
/**
* Handle mouse move for blob hover detection.
*/
function _onBlobMouseMove(event) {
if (!_camera || !_scene || _blobs3D.size === 0) return;
// Calculate mouse position in normalized device coordinates
var rect = event.target.getBoundingClientRect();
_mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
_mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
// Raycast to find hovered blob
_raycaster.setFromCamera(_mouse, _camera);
var blobMeshes = [];
_blobs3D.forEach(function(obj) {
if (obj.group) {
blobMeshes.push(obj.group);
}
});
var intersects = _raycaster.intersectObjects(blobMeshes, true);
if (intersects.length > 0) {
// Find the blob object from the intersected mesh
var intersected = intersects[0].object;
var blobObj = null;
// Walk up the parent chain to find the group
var current = intersected;
while (current) {
_blobs3D.forEach(function(obj, id) {
if (obj.group === current) {
blobObj = obj;
}
});
if (blobObj) break;
current = current.parent;
}
if (blobObj && blobObj !== _hoveredBlob) {
_hoveredBlob = blobObj;
_showBlobFeedbackTooltip(event, blobObj);
} else if (blobObj) {
// Update tooltip position
_updateTooltipPosition(event);
}
} else {
if (_hoveredBlob) {
_hideBlobFeedbackTooltip();
}
}
}
/**
* Show feedback tooltip for a blob.
*/
function _showBlobFeedbackTooltip(event, blobObj) {
if (!_feedbackTooltip) return;
var blobId = blobObj.blobId;
var eventType = 'blob_detection';
var eventTime = blobObj.createdAt || Date.now();
var position = blobObj.lastPosition || { x: 0, z: 0 };
_feedbackTooltip.innerHTML =
'<div class="feedback-tooltip-content">' +
' <div class="feedback-tooltip-label">Track #' + blobId + '</div>' +
' <div class="feedback-tooltip-actions">' +
' <button class="feedback-btn-icon feedback-thumbs-up" title="Correct detection" ' +
' onclick="Viz3D.submitBlobFeedback(' + blobId + ', \'TRUE_POSITIVE\')">&#x1F44D;</button>' +
' <button class="feedback-btn-icon feedback-thumbs-down" title="Incorrect detection" ' +
' onclick="Viz3D.showBlobFeedbackForm(' + blobId + ')">&#x1F44E;</button>' +
' </div>' +
'</div>';
_feedbackTooltip.style.display = 'block';
_updateTooltipPosition(event);
// Store blob data for feedback submission
_feedbackTooltip.dataset.blobId = blobId;
_feedbackTooltip.dataset.eventType = eventType;
_feedbackTooltip.dataset.eventTime = eventTime;
_feedbackTooltip.dataset.posX = position.x;
_feedbackTooltip.dataset.posZ = position.z;
}
/**
* Update tooltip position to follow cursor.
*/
function _updateTooltipPosition(event) {
if (!_feedbackTooltip) return;
var offsetX = 15;
var offsetY = 15;
_feedbackTooltip.style.left = (event.clientX + offsetX) + 'px';
_feedbackTooltip.style.top = (event.clientY + offsetY) + 'px';
}
/**
* Hide the blob feedback tooltip.
*/
function _hideBlobFeedbackTooltip() {
if (_feedbackTooltip) {
_feedbackTooltip.style.display = 'none';
}
_hoveredBlob = null;
}
/**
* Submit feedback for a blob detection.
* @param {number} blobId - The blob ID
* @param {string} feedbackType - Feedback type (TRUE_POSITIVE, FALSE_POSITIVE, etc.)
*/
function submitBlobFeedback(blobId, feedbackType) {
var blobObj = _blobs3D.get(blobId);
if (!blobObj) return;
var details = {
position_x: blobObj.lastPosition ? blobObj.lastPosition.x : 0,
position_z: blobObj.lastPosition ? blobObj.lastPosition.z : 0
};
// Use Feedback module if available
if (window.Feedback) {
window.Feedback.sendFeedback(
'blob-' + blobId + '-' + (blobObj.createdAt || Date.now()),
window.Feedback.EventTypes.BLOB_DETECTION,
feedbackType,
details
);
} else {
// Direct API call
fetch('/api/learning/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event_id: 'blob-' + blobId + '-' + (blobObj.createdAt || Date.now()),
event_type: 'blob_detection',
feedback_type: feedbackType,
details: details
})
}).then(function(res) { return res.json(); })
.then(function(result) {
console.log('[Viz3D] Feedback submitted:', feedbackType);
})
.catch(function(err) {
console.error('[Viz3D] Failed to submit feedback:', err);
});
}
_hideBlobFeedbackTooltip();
}
/**
* Show the feedback form for a blob (thumbs-down flow).
* @param {number} blobId - The blob ID
*/
function showBlobFeedbackForm(blobId) {
var blobObj = _blobs3D.get(blobId);
if (!blobObj) return;
var details = {
position_x: blobObj.lastPosition ? blobObj.lastPosition.x : 0,
position_z: blobObj.lastPosition ? blobObj.lastPosition.z : 0
};
if (window.Feedback) {
window.Feedback.showFeedbackPanel(
'blob-' + blobId + '-' + (blobObj.createdAt || Date.now()),
window.Feedback.EventTypes.BLOB_DETECTION,
blobObj.createdAt || Date.now(),
details
);
}
_hideBlobFeedbackTooltip();
}
// ── Blob Context Menu ───────────────────────────────────────────────────────
let _blobContextMenu = null;
/**
* Handle context menu (right-click) on blobs.
*/
function _onBlobContextMenu(event) {
event.preventDefault();
if (!_camera || !_scene || _blobs3D.size === 0) return;
// Calculate mouse position in normalized device coordinates
var rect = event.target.getBoundingClientRect();
_mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
_mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
// Raycast to find clicked blob
_raycaster.setFromCamera(_mouse, _camera);
var blobMeshes = [];
_blobs3D.forEach(function(obj) {
if (obj.group) {
blobMeshes.push(obj.group);
}
});
var intersects = _raycaster.intersectObjects(blobMeshes, true);
if (intersects.length > 0) {
// Find the blob object from the intersected mesh
var intersected = intersects[0].object;
var blobObj = null;
// Walk up the parent chain to find the group
var current = intersected;
while (current) {
_blobs3D.forEach(function(obj, id) {
if (obj.group === current) {
blobObj = obj;
}
});
if (blobObj) break;
current = current.parent;
}
if (blobObj) {
_showBlobContextMenu(event, blobObj);
}
}
}
/**
* Show context menu for a blob.
*/
function _showBlobContextMenu(event, blobObj) {
// Remove existing context menu
_hideBlobContextMenu();
var blobId = blobObj.blobId;
// Create context menu element
var menu = document.createElement('div');
menu.className = 'blob-context-menu';
menu.innerHTML =
'<div class="blob-context-menu-item" onclick="Viz3D.explainBlob(' + blobId + ')">' +
' <span class="blob-context-menu-icon">&#128526;</span>' +
' Why is this here?' +
'</div>' +
'<div class="blob-context-menu-divider"></div>' +
'<div class="blob-context-menu-item" onclick="Viz3D.submitBlobFeedback(' + blobId + ', \'TRUE_POSITIVE\')">' +
' <span class="blob-context-menu-icon">&#x1F44D;</span>' +
' Correct detection' +
'</div>' +
'<div class="blob-context-menu-item" onclick="Viz3D.showBlobFeedbackForm(' + blobId + ')">' +
' <span class="blob-context-menu-icon">&#x1F44E;</span>' +
' Incorrect detection' +
'</div>';
// Position menu at cursor
menu.style.left = event.clientX + 'px';
menu.style.top = event.clientY + 'px';
document.body.appendChild(menu);
_blobContextMenu = menu;
// Prevent menu from going off screen
var rect = menu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menu.style.left = (event.clientX - rect.width) + 'px';
}
if (rect.bottom > window.innerHeight) {
menu.style.top = (event.clientY - rect.height) + 'px';
}
}
/**
* Hide the blob context menu.
*/
function _hideBlobContextMenu() {
if (_blobContextMenu) {
document.body.removeChild(_blobContextMenu);
_blobContextMenu = null;
}
}
/**
* Open explainability view for a blob.
* @param {number} blobId - The blob ID to explain
*/
function explainBlob(blobId) {
_hideBlobContextMenu();
if (window.Explainability) {
window.Explainability.explain(blobId);
} else {
console.error('[Viz3D] Explainability module not loaded');
}
}
/**
* Add blob feedback tooltip styles.
*/
function _addBlobFeedbackStyles() {
if (document.getElementById('blob-feedback-styles')) return;
var style = document.createElement('style');
style.id = 'blob-feedback-styles';
style.textContent =
'.blob-feedback-tooltip {' +
' position: fixed;' +
' background: rgba(0, 0, 0, 0.9);' +
' border-radius: 6px;' +
' padding: 8px 12px;' +
' z-index: 1000;' +
' pointer-events: auto;' +
' box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);' +
'}' +
'.feedback-tooltip-content {' +
' display: flex;' +
' flex-direction: column;' +
' gap: 6px;' +
'}' +
'.feedback-tooltip-label {' +
' font-size: 11px;' +
' color: #888;' +
' text-align: center;' +
'}' +
'.feedback-tooltip-actions {' +
' display: flex;' +
' gap: 6px;' +
' justify-content: center;' +
'}' +
'.feedback-btn-icon {' +
' background: rgba(255, 255, 255, 0.1);' +
' border: none;' +
' width: 32px;' +
' height: 32px;' +
' border-radius: 50%;' +
' cursor: pointer;' +
' font-size: 16px;' +
' display: flex;' +
' align-items: center;' +
' justify-content: center;' +
' transition: background 0.2s;' +
'}' +
'.feedback-btn-icon:hover {' +
' background: rgba(255, 255, 255, 0.2);' +
'}' +
'.feedback-thumbs-up:hover {' +
' background: rgba(76, 175, 80, 0.4);' +
'}' +
'.feedback-thumbs-down:hover {' +
' background: rgba(244, 67, 54, 0.4);' +
'}';
document.head.appendChild(style);
}
// ── message handlers ──────────────────────────────────────────────────────
function handleRegistryState(msg) {
applyRoom(msg.room);
applyNodeRegistry(msg.nodes || []);
}
function handleLocUpdate(msg) {
applyLocUpdate(msg.blobs || []);
}
function handleLinkActive(msg) {
const id = msg.id || `${msg.node_mac}:${msg.peer_mac}`;
_activeLinks.set(id, msg);
// Also store health if provided
if (msg.health_score !== undefined) {
_linkHealth.set(id, {
score: msg.health_score,
details: msg.health_details || {},
last_updated: msg.last_updated
});
}
_rebuildLinkLines();
}
function handleLinkInactive(msg) {
_activeLinks.delete(msg.id);
_linkHealth.delete(msg.id);
const line = _linkLines.get(msg.id);
if (line) { _scene.remove(line); _linkLines.delete(msg.id); }
}
// ── view presets ──────────────────────────────────────────────────────────
function setViewPreset(preset, blobId) {
_followId = null;
_controls.enabled = true;
const cx = _room ? (_room.origin_x||0) + _room.width / 2 : 5;
const cz = _room ? (_room.origin_z||0) + _room.depth / 2 : 5;
const h = _room ? _room.height : 2.5;
if (preset === 'topdown') {
_camera.up.set(0, 0, -1);
_camera.position.set(cx, Math.max(h * 4, 12), cz);
_controls.target.set(cx, 0, cz);
_controls.update();
} else if (preset === 'perspective') {
_camera.up.set(0, 1, 0);
_camera.position.set(cx + 8, h * 2.5, cz + 8);
_controls.target.set(cx, 0, cz);
_controls.update();
} else if (preset === 'follow') {
_camera.up.set(0, 1, 0);
_followId = (blobId !== undefined) ? blobId
: (_blobs3D.size > 0 ? _blobs3D.keys().next().value : null);
}
}
// ── ghost node for repositioning advice ───────────────────────────────────
/**
* Set a ghost node at the target position, connected by a dashed line
* to the original node's current position.
* @param {string} nodeMAC - The MAC address of the node to move
* @param {number} x - Target X position in meters
* @param {number} y - Target Y position (height) in meters
* @param {number} z - Target Z position in meters
*/
function setGhostNode(nodeMAC, x, y, z) {
// Clear any existing ghost
clearGhostNode();
var originalMesh = _nodeMeshes.get(nodeMAC);
if (!originalMesh) {
console.warn('[Viz3D] Cannot set ghost node: original node not found:', nodeMAC);
return;
}
_ghostNodeMAC = nodeMAC;
// Create translucent ghost node mesh
var ghostGeo = new THREE.OctahedronGeometry(0.14, 0); // Slightly larger
var ghostMat = new THREE.MeshPhongMaterial({
color: 0x66bb6a, // Green for "go here"
emissive: 0x66bb6a,
emissiveIntensity: 0.4,
transparent: true,
opacity: 0.5,
shininess: 40
});
_ghostNode = new THREE.Mesh(ghostGeo, ghostMat);
_ghostNode.position.set(x, y !== undefined ? y : 1.5, z);
_scene.add(_ghostNode);
// Create dashed line from original to ghost
var origPos = originalMesh.position;
var ghostPos = new THREE.Vector3(x, y !== undefined ? y : 1.5, z);
var lineGeo = new THREE.BufferGeometry().setFromPoints([origPos.clone(), ghostPos]);
var lineMat = new THREE.LineDashedMaterial({
color: 0x66bb6a,
dashSize: 0.1,
gapSize: 0.05,
transparent: true,
opacity: 0.7
});
_ghostLine = new THREE.Line(lineGeo, lineMat);
_ghostLine.computeLineDistances();
_scene.add(_ghostLine);
console.log('[Viz3D] Ghost node set at', x, y, z, 'for', nodeMAC);
}
/**
* Clear the ghost node and dashed line.
*/
function clearGhostNode() {
if (_ghostNode) {
_scene.remove(_ghostNode);
_ghostNode.geometry.dispose();
_ghostNode.material.dispose();
_ghostNode = null;
}
if (_ghostLine) {
_scene.remove(_ghostLine);
_ghostLine.geometry.dispose();
_ghostLine.material.dispose();
_ghostLine = null;
}
_ghostNodeMAC = null;
}
/**
* Update the ghost line if the original node has moved.
* Called from the main update loop.
*/
function _updateGhostLine() {
if (!_ghostLine || !_ghostNodeMAC) return;
var originalMesh = _nodeMeshes.get(_ghostNodeMAC);
if (!originalMesh) return;
var origPos = originalMesh.position;
var ghostPos = _ghostNode.position;
_ghostLine.geometry.setFromPoints([origPos.clone(), ghostPos]);
_ghostLine.computeLineDistances();
}
// ── Flow Analytics Layers ────────────────────────────────────────────────────
// State for analytics layers
let _flowLayerVisible = false;
let _dwellLayerVisible = false;
let _corridorLayerVisible = false;
let _flowArrows = []; // Array of THREE.ArrowHelper
let _dwellPlanes = []; // Array of THREE.Mesh (heatmap cells)
let _corridorMeshes = []; // Array of THREE.Mesh (corridor regions)
let _flowAnimTime = 0;
let _flowData = null;
let _dwellData = null;
let _corridorData = null;
let _flowPersonFilter = '';
let _flowTimeFilter = '30d'; // '7d', '30d', 'all'
/**
* Set visibility of flow arrows layer.
* @param {boolean} visible
*/
function setFlowLayerVisible(visible) {
_flowLayerVisible = visible;
_flowArrows.forEach(function(arrow) {
arrow.visible = visible;
});
if (visible && !_flowData) {
fetchFlowData();
}
}
/**
* Set visibility of dwell heatmap layer.
* @param {boolean} visible
*/
function setDwellLayerVisible(visible) {
_dwellLayerVisible = visible;
_dwellPlanes.forEach(function(plane) {
plane.visible = visible;
});
if (visible && !_dwellData) {
fetchDwellData();
}
}
/**
* Set visibility of corridor overlay layer.
* @param {boolean} visible
*/
function setCorridorLayerVisible(visible) {
_corridorLayerVisible = visible;
_corridorMeshes.forEach(function(mesh) {
mesh.visible = visible;
});
if (visible && !_corridorData) {
fetchCorridorData();
}
}
/**
* Set person filter for flow/dwell data.
* @param {string} personId - Empty string for all people
*/
function setFlowPersonFilter(personId) {
if (_flowPersonFilter !== personId) {
_flowPersonFilter = personId;
_flowData = null;
_dwellData = null;
if (_flowLayerVisible) fetchFlowData();
if (_dwellLayerVisible) fetchDwellData();
}
}
/**
* Set time filter for flow data.
* @param {string} timeFilter - '7d', '30d', or 'all'
*/
function setFlowTimeFilter(timeFilter) {
if (_flowTimeFilter !== timeFilter) {
_flowTimeFilter = timeFilter;
_flowData = null;
if (_flowLayerVisible) fetchFlowData();
}
}
/**
* Fetch flow data from API and update visualization.
*/
function fetchFlowData() {
var since = 0;
var now = Date.now() / 1000;
if (_flowTimeFilter === '7d') {
since = now - 7 * 24 * 3600;
} else if (_flowTimeFilter === '30d') {
since = now - 30 * 24 * 3600;
}
var url = '/api/analytics/flow?since=' + since + '&until=' + now;
if (_flowPersonFilter) {
url += '&person_id=' + encodeURIComponent(_flowPersonFilter);
}
fetch(url)
.then(function(response) { return response.json(); })
.then(function(data) {
_flowData = data;
rebuildFlowArrows();
})
.catch(function(err) {
console.error('[Viz3D] Failed to fetch flow data:', err);
});
}
/**
* Fetch dwell heatmap data from API and update visualization.
*/
function fetchDwellData() {
var url = '/api/analytics/dwell';
if (_flowPersonFilter) {
url += '?person_id=' + encodeURIComponent(_flowPersonFilter);
}
fetch(url)
.then(function(response) { return response.json(); })
.then(function(data) {
_dwellData = data;
rebuildDwellPlanes();
})
.catch(function(err) {
console.error('[Viz3D] Failed to fetch dwell data:', err);
});
}
/**
* Fetch corridor data from API and update visualization.
*/
function fetchCorridorData() {
fetch('/api/analytics/corridors')
.then(function(response) { return response.json(); })
.then(function(data) {
_corridorData = data;
rebuildCorridorMeshes();
})
.catch(function(err) {
console.error('[Viz3D] Failed to fetch corridor data:', err);
});
}
/**
* Rebuild flow arrow meshes from _flowData.
*/
function rebuildFlowArrows() {
// Clear existing arrows
_flowArrows.forEach(function(arrow) {
_scene.remove(arrow);
});
_flowArrows = [];
if (!_flowData || !_flowData.cells) return;
var gridSize = _flowData.grid_size || 0.25;
_flowData.cells.forEach(function(cell) {
var cx = (cell.grid_x + 0.5) * gridSize;
var cz = (cell.grid_z + 0.5) * gridSize;
// Direction vector
var dir = new THREE.Vector3(cell.vector_x, 0, cell.vector_z).normalize();
var length = Math.min(Math.sqrt(cell.vector_x * cell.vector_x + cell.vector_z * cell.vector_z) * 0.5 + 0.1, 0.4);
// Color based on segment count (blue to red)
var intensity = Math.min(cell.segment_count / 50, 1);
var color = new THREE.Color();
color.setHSL(0.6 - intensity * 0.6, 0.8, 0.5); // Blue (0.6) to Red (0)
var arrow = new THREE.ArrowHelper(
dir,
new THREE.Vector3(cx, 0.02, cz),
length,
color.getHex(),
length * 0.3, // headLength
length * 0.15 // headWidth
);
arrow.visible = _flowLayerVisible;
arrow.userData = { segmentCount: cell.segment_count, baseOpacity: 0.7 };
_scene.add(arrow);
_flowArrows.push(arrow);
});
console.log('[Viz3D] Built', _flowArrows.length, 'flow arrows');
}
/**
* Rebuild dwell heatmap planes from _dwellData.
*/
function rebuildDwellPlanes() {
// Clear existing planes
_dwellPlanes.forEach(function(plane) {
_scene.remove(plane);
plane.geometry.dispose();
plane.material.dispose();
});
_dwellPlanes = [];
if (!_dwellData || !_dwellData.cells) return;
var gridSize = 0.25; // GridCellSize
_dwellData.cells.forEach(function(cell) {
var cx = (cell.grid_x + 0.5) * gridSize;
var cz = (cell.grid_z + 0.5) * gridSize;
// Color: blue (low) -> green (mid) -> red (high)
var normalized = cell.normalized;
var color = new THREE.Color();
if (normalized < 0.5) {
// Blue to green
color.setHSL(0.55 + normalized * 0.1, 0.8, 0.4);
} else {
// Green to red
color.setHSL(0.35 - (normalized - 0.5) * 0.7, 0.8, 0.45);
}
var geo = new THREE.PlaneGeometry(gridSize * 0.95, gridSize * 0.95);
var mat = new THREE.MeshBasicMaterial({
color: color.getHex(),
transparent: true,
opacity: 0.4,
side: THREE.DoubleSide
});
var plane = new THREE.Mesh(geo, mat);
plane.rotation.x = -Math.PI / 2;
plane.position.set(cx, 0.015, cz);
plane.visible = _dwellLayerVisible;
plane.userData = { count: cell.count, normalized: normalized };
_scene.add(plane);
_dwellPlanes.push(plane);
});
console.log('[Viz3D] Built', _dwellPlanes.length, 'dwell heatmap cells');
}
/**
* Rebuild corridor region meshes from _corridorData.
*/
function rebuildCorridorMeshes() {
// Clear existing meshes
_corridorMeshes.forEach(function(mesh) {
_scene.remove(mesh);
mesh.geometry.dispose();
mesh.material.dispose();
});
_corridorMeshes = [];
if (!_corridorData || !Array.isArray(_corridorData)) return;
_corridorData.forEach(function(corridor) {
// Create an extruded rectangle for the corridor region
var length = corridor.length_m;
var width = corridor.width_m;
var cx = corridor.centroid_x;
var cz = corridor.centroid_z;
// Compute rotation from dominant direction
var angle = Math.atan2(corridor.dominant_dir_x, corridor.dominant_dir_z);
var geo = new THREE.PlaneGeometry(length, width);
var mat = new THREE.MeshBasicMaterial({
color: 0x8899aa, // Warm grey
transparent: true,
opacity: 0.3,
side: THREE.DoubleSide
});
var mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = -Math.PI / 2;
mesh.rotation.z = angle;
mesh.position.set(cx, 0.025, cz); // Slightly raised
mesh.visible = _corridorLayerVisible;
mesh.userData = { corridor: corridor };
_scene.add(mesh);
_corridorMeshes.push(mesh);
});
console.log('[Viz3D] Built', _corridorMeshes.length, 'corridor regions');
}
/**
* Update flow arrow animation (called from main update loop).
* @param {number} dt - Delta time in seconds
*/
function updateFlowAnimation(dt) {
if (!_flowLayerVisible) return;
_flowAnimTime += dt;
// 2-second loop for flowing effect
var phase = (_flowAnimTime % 2.0) / 2.0;
_flowArrows.forEach(function(arrow, index) {
// Stagger animation based on arrow position
var stagger = (arrow.position.x * 0.5 + arrow.position.z * 0.3) % 1.0;
var localPhase = (phase + stagger) % 1.0;
// Animate opacity: 0.3 -> 1.0 -> 0.3
var opacity = 0.3 + 0.7 * (1 - Math.abs(localPhase - 0.5) * 2);
if (arrow.line && arrow.line.material) {
arrow.line.material.opacity = opacity;
arrow.line.material.transparent = true;
}
if (arrow.cone && arrow.cone.material) {
arrow.cone.material.opacity = opacity;
arrow.cone.material.transparent = true;
}
});
}
/**
* Refresh all analytics data.
*/
function refreshAnalyticsData() {
if (_flowLayerVisible) fetchFlowData();
if (_dwellLayerVisible) fetchDwellData();
if (_corridorLayerVisible) fetchCorridorData();
}
/**
* Get current analytics layer visibility state.
*/
function getAnalyticsLayerState() {
return {
flow: _flowLayerVisible,
dwell: _dwellLayerVisible,
corridor: _corridorLayerVisible,
personFilter: _flowPersonFilter,
timeFilter: _flowTimeFilter
};
}
// ── Anomaly Zone Pulsing ─────────────────────────────────────────────────────
let _anomalyZones = []; // Array of zone IDs with active anomalies
let _anomalyMeshes = new Map(); // zoneID -> THREE.Mesh (pulsing overlay)
let _anomalyPulseTime = 0;
/**
* Set which zones have active anomalies (will pulse red).
* @param {Array} zoneIDs - Array of zone ID strings
*/
function setAnomalyZones(zoneIDs) {
_anomalyZones = zoneIDs || [];
// Remove meshes for zones no longer anomalous
_anomalyMeshes.forEach(function(mesh, zoneID) {
if (_anomalyZones.indexOf(zoneID) === -1) {
_scene.remove(mesh);
mesh.geometry.dispose();
mesh.material.dispose();
_anomalyMeshes.delete(zoneID);
}
});
// Add meshes for new anomalous zones
_anomalyZones.forEach(function(zoneID) {
if (!_anomalyMeshes.has(zoneID)) {
// Create a pulsing red overlay for this zone
// Default to center of room if zone position unknown
var cx = _room ? (_room.origin_x || 0) + _room.width / 2 : 3;
var cz = _room ? (_room.origin_z || 0) + _room.depth / 2 : 2.5;
// Try to get zone-specific position from zone provider
// For now, use a 1x1m red overlay at the zone center
var geo = new THREE.PlaneGeometry(1.5, 1.5);
var mat = new THREE.MeshBasicMaterial({
color: 0xef4444,
transparent: true,
opacity: 0.4,
side: THREE.DoubleSide,
depthWrite: false
});
var mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = -Math.PI / 2;
mesh.position.set(cx, 0.03, cz);
mesh.userData.zoneID = zoneID;
_scene.add(mesh);
_anomalyMeshes.set(zoneID, mesh);
}
});
console.log('[Viz3D] Anomaly zones updated:', _anomalyZones);
}
/**
* Update anomaly pulse animation (called from main update loop).
* @param {number} dt - Delta time in seconds
*/
function updateAnomalyPulse(dt) {
if (_anomalyMeshes.size === 0) return;
_anomalyPulseTime += dt;
// 1.5 second pulse cycle
var phase = (_anomalyPulseTime % 1.5) / 1.5;
// Opacity oscillates: 0.2 -> 0.6 -> 0.2
var opacity = 0.2 + 0.4 * (1 - Math.abs(phase - 0.5) * 2);
_anomalyMeshes.forEach(function(mesh) {
mesh.material.opacity = opacity;
});
}
/**
* Focus the camera on a specific zone.
* @param {string} zoneID - The zone ID to focus on
*/
function focusOnZone(zoneID) {
if (!_camera || !_controls) return;
// Get zone position from anomaly mesh if available
var mesh = _anomalyMeshes.get(zoneID);
if (mesh) {
var pos = mesh.position;
_camera.position.set(pos.x + 2, 2.0, pos.z + 3);
_controls.target.set(pos.x, 0.5, pos.z);
_controls.update();
return;
}
// Fallback: focus on room center
var cx = _room ? (_room.origin_x || 0) + _room.width / 2 : 3;
var cz = _room ? (_room.origin_z || 0) + _room.depth / 2 : 2.5;
_camera.position.set(cx + 2, 2.0, cz + 3);
_controls.target.set(cx, 0.5, cz);
_controls.update();
}
/**
* Focus the camera on a specific position.
* @param {number} x - X coordinate
* @param {number} y - Y coordinate (height)
* @param {number} z - Z coordinate
*/
function focusOnPosition(x, y, z) {
if (!_camera || !_controls) return;
_camera.position.set(x + 2, Math.max(y + 1, 2.0), z + 3);
_controls.target.set(x, y, z);
_controls.update();
}
/**
* Clear all anomaly zone overlays.
*/
function clearAnomalyZones() {
_anomalyMeshes.forEach(function(mesh) {
_scene.remove(mesh);
mesh.geometry.dispose();
mesh.material.dispose();
});
_anomalyMeshes.clear();
_anomalyZones = [];
}
// ── Fresnel zone ellipsoid rendering for explainability ───────────────────────
// Configuration for Fresnel zone visualization
const FRESNEL_CONFIG = {
color: 0x4FC3F7, // Blue for Fresnel zones
opacity: 0.25, // Opacity for Fresnel zones
};
let _fresnelZones = []; // Array of THREE.Mesh for explainability Fresnel zones
/**
* Add a Fresnel zone ellipsoid to the scene.
* Used by the explainability module to visualize contributing links.
* @param {number} cx, cy, cz - Center position
* @param {number} sx, sy, sz - Semi-axes
* @param {number} color - Color hex value
* @param {number} opacity - Material opacity
* @returns {THREE.Mesh|null} The created mesh
*/
function addFresnelZone(cx, cy, cz, sx, sy, sz, color, opacity) {
if (!_scene) return null;
// Create ellipsoid using SphereGeometry scaled to semi-axes
// THREE.SphereGeometry(radius, widthSegments, heightSegments)
var geometry = new THREE.SphereGeometry(1, 32, 32);
geometry.scale(sx, sy, sz);
var material = new THREE.MeshBasicMaterial({
color: color || FRESNEL_CONFIG.color,
transparent: true,
opacity: opacity || FRESNEL_CONFIG.opacity,
side: THREE.DoubleSide,
depthWrite: false,
wireframe: false
});
var mesh = new THREE.Mesh(geometry, material);
mesh.position.set(cx, cy, cz);
_scene.add(mesh);
_fresnelZones.push(mesh);
return mesh;
}
/**
* Remove a Fresnel zone mesh from the scene.
* @param {THREE.Mesh} mesh - The mesh to remove
*/
function removeFresnelZone(mesh) {
if (!mesh || !_scene) return;
_scene.remove(mesh);
mesh.geometry.dispose();
mesh.material.dispose();
var idx = _fresnelZones.indexOf(mesh);
if (idx !== -1) {
_fresnelZones.splice(idx, 1);
}
}
/**
* Clear all Fresnel zone meshes.
*/
function clearFresnelZones() {
_fresnelZones.forEach(function(mesh) {
if (_scene) {
_scene.remove(mesh);
}
mesh.geometry.dispose();
mesh.material.dispose();
});
_fresnelZones = [];
}
// ── WebSocket reconnect helpers ─────────────────────────────────────────
/**
* Clear all blob trails (called on reconnect).
*/
function clearAllTrails() {
_blobs3D.forEach(function (obj) {
var arr = obj.trail.geometry.attributes.position.array;
arr.fill(0);
obj.trail.geometry.attributes.position.needsUpdate = true;
obj.trail.geometry.setDrawRange(0, 0);
});
}
/**
* Extrapolate a single blob's position during disconnect.
* @param {number} blobId
* @param {number} x - new X position
* @param {number} z - new Z position
*/
function extrapolateBlobPosition(blobId, x, z) {
var obj = _blobs3D.get(blobId);
if (!obj) return;
obj.group.position.set(x, 0, z);
}
/**
* Get current blob states for extrapolation on disconnect.
* Returns array of { id, x, z, vx, vz } for each tracked blob.
* @returns {Array}
*/
function getBlobStates() {
var states = [];
_blobs3D.forEach(function (obj, blobId) {
states.push({
id: blobId,
x: obj.lastPosition ? obj.lastPosition.x : 0,
z: obj.lastPosition ? obj.lastPosition.z : 0,
vx: obj.lastVelocity ? obj.lastVelocity.vx : 0,
vz: obj.lastVelocity ? obj.lastVelocity.vz : 0
});
});
return states;
}
// ── Public API ────────────────────────────────────────────────────────────
return {
init,
update,
handleRegistryState,
handleLocUpdate,
handleLinkActive,
handleLinkInactive,
applyLinks,
uploadFloorPlan,
setViewPreset,
// WebSocket reconnect helpers
clearAllTrails: clearAllTrails,
extrapolateBlobPosition: extrapolateBlobPosition,
getBlobStates: getBlobStates,
getNodeMesh: function (mac) { return _nodeMeshes.get(mac); },
rebuildLinkLines: _rebuildLinkLines,
// Ghost node API
setGhostNode: setGhostNode,
clearGhostNode: clearGhostNode,
// Link health API
updateLinkHealth: updateLinkHealth,
getLinkHealth: getLinkHealth,
getAllLinkHealth: getAllLinkHealth,
// Analytics layers API
setFlowLayerVisible: setFlowLayerVisible,
setDwellLayerVisible: setDwellLayerVisible,
setCorridorLayerVisible: setCorridorLayerVisible,
setFlowPersonFilter: setFlowPersonFilter,
setFlowTimeFilter: setFlowTimeFilter,
refreshAnalyticsData: refreshAnalyticsData,
getAnalyticsLayerState: getAnalyticsLayerState,
// Blob feedback API
initBlobInteraction: initBlobInteraction,
submitBlobFeedback: submitBlobFeedback,
showBlobFeedbackForm: showBlobFeedbackForm,
// Identity API
updateIdentities: updateIdentities,
getBlobIdentity: getBlobIdentity,
clearIdentities: clearIdentities,
// Anomaly zone API
setAnomalyZones: setAnomalyZones,
focusOnZone: focusOnZone,
focusOnPosition: focusOnPosition,
clearAnomalyZones: clearAnomalyZones,
// Explainability support API
forEachRoomObject: function(callback) {
if (!_roomObjs) return;
var room = _roomObjs;
if (room.floor) callback(room.floor);
if (room.ceiling) callback(room.ceiling);
room.walls.forEach(function(w) { callback(w); });
if (room.edges) callback(room.edges);
},
forEachLink: function(callback) {
_linkLines.forEach(function(line, linkID) {
callback(line, linkID);
});
},
forEachBlob: function(callback) {
_blobs3D.forEach(function(obj, blobID) {
callback(obj, blobID);
});
},
highlightLink: function(linkID, color, emissiveColor, emissiveIntensity) {
var line = _linkLines.get(linkID);
if (!line) return;
line.material.opacity = 1.0;
line.material.transparent = false;
if (line.material.color) {
line.material.color.setHex(color);
}
if (line.material.emissive) {
line.material.emissive.setHex(emissiveColor);
line.material.emissiveIntensity = emissiveIntensity;
}
line.material.needsUpdate = true;
},
restoreObjectMaterial: function(uuid, state) {
// Search for object by UUID in room, links, and blobs
var found = false;
if (_roomObjs) {
[_roomObjs.floor, _roomObjs.ceiling, _roomObjs.edges].concat(_roomObjs.walls).forEach(function(obj) {
if (obj && obj.uuid === uuid) {
if (state.opacity !== undefined) obj.material.opacity = state.opacity;
if (state.transparent !== undefined) obj.material.transparent = state.transparent;
if (obj.material.emissive && state.emissiveIntensity !== undefined) {
obj.material.emissiveIntensity = state.emissiveIntensity;
}
if (obj.material.emissive && state.emissiveColor) {
obj.material.emissive.setHex(state.emissiveColor);
}
if (obj.material.color && state.color) {
obj.material.color.setHex(state.color);
}
obj.material.needsUpdate = true;
found = true;
}
});
}
_linkLines.forEach(function(line) {
if (line.uuid === uuid) {
if (state.opacity !== undefined) line.material.opacity = state.opacity;
if (state.transparent !== undefined) line.material.transparent = state.transparent;
if (line.material.emissive && state.emissiveIntensity !== undefined) {
line.material.emissiveIntensity = state.emissiveIntensity;
}
if (line.material.emissive && state.emissiveColor) {
line.material.emissive.setHex(state.emissiveColor);
}
if (line.material.color && state.color) {
line.material.color.setHex(state.color);
}
line.material.needsUpdate = true;
found = true;
}
});
_blobs3D.forEach(function(obj) {
if (obj.group && obj.group.uuid === uuid) {
if (state.opacity !== undefined) obj.group.material.opacity = state.opacity;
if (state.transparent !== undefined) obj.group.material.transparent = state.transparent;
if (obj.group.material.emissive && state.emissiveIntensity !== undefined) {
obj.group.material.emissiveIntensity = state.emissiveIntensity;
}
if (obj.group.material.emissive && state.emissiveColor) {
obj.group.material.emissive.setHex(state.emissiveColor);
}
if (obj.group.material.color && state.color) {
obj.group.material.color.setHex(state.color);
}
obj.group.material.needsUpdate = true;
found = true;
}
});
},
addFresnelZone: addFresnelZone,
removeFresnelZone: removeFresnelZone,
clearFresnelZones: clearFresnelZones,
};
})();