/**
* 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;
// 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
// Zone and portal rendering state
let _zoneMeshes = new Map(); // zoneID -> { mesh, label, occupantsLabel }
let _portalMeshes = new Map(); // portalID -> { mesh, label, flashEndTime }
let _zonesVisible = true; // Toggle state for zones layer
let _portalsVisible = true; // Toggle state for portals layer
let _currentZones = new Map(); // zoneID -> zone data
let _currentPortals = new Map(); // portalID -> portal data
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);
// Update portal flash animations
updatePortalFlashes(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;
// Apply floor plan calibration if texture and calibration data exist
if (_floorTex && _floorCalibration.metersPerPixel !== 1) {
_applyCalibrationToFloor();
}
// 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);
});
}
// Calibration state for floor plan
let _floorCalibration = {
metersPerPixel: 1,
rotationDeg: 0,
ax: 0, ay: 0, bx: 0, by: 0,
distanceM: 1
};
/**
* Apply floor plan calibration to the ground plane texture.
* @param {Object} calibration - Calibration data from API
* @param {number} calibration.meters_per_pixel - Scale factor
* @param {number} calibration.rotation_deg - Rotation angle in degrees
* @param {number} calibration.cal_ax - Point A X coordinate
* @param {number} calibration.cal_ay - Point A Y coordinate
* @param {number} calibration.cal_bx - Point B X coordinate
* @param {number} calibration.cal_by - Point B Y coordinate
* @param {number} calibration.distance_m - Real-world distance in meters
*/
function setFloorPlanCalibration(calibration) {
if (!calibration) return;
_floorCalibration = {
metersPerPixel: calibration.meters_per_pixel || 1,
rotationDeg: calibration.rotation_deg || 0,
ax: calibration.cal_ax || 0,
ay: calibration.cal_ay || 0,
bx: calibration.cal_bx || 0,
by: calibration.cal_by || 0,
distanceM: calibration.distance_m || 1
};
// Apply calibration to floor texture if floor exists
if (_roomObjs.floor && _floorTex) {
_applyCalibrationToFloor();
}
}
/**
* Apply stored calibration to the floor mesh.
* Uses texture transformation matrix to scale and rotate.
*/
function _applyCalibrationToFloor() {
if (!_roomObjs.floor || !_floorTex) return;
const floor = _roomObjs.floor;
const tex = _floorTex;
// Enable texture transformation
tex.matrixAutoUpdate = false;
// Calculate texture scale based on room dimensions vs image dimensions
// Default room size if not set
const roomWidth = _room ? _room.width : 10;
const roomDepth = _room ? _room.depth : 10;
// Calculate how many meters the image covers at current scale
const imageWidthMeters = tex.image.width * _floorCalibration.metersPerPixel;
const imageHeightMeters = tex.image.height * _floorCalibration.metersPerPixel;
// Scale texture to fit room
const scaleX = roomWidth / imageWidthMeters;
const scaleY = roomDepth / imageHeightMeters;
// Build transformation matrix: center -> rotate -> scale -> center back
tex.matrix.setUvTransform(
0.5, 0.5, // center
scaleX, scaleY, // scale
_floorCalibration.rotationDeg * Math.PI / 180, // rotation
0, 0 // translation
);
floor.material.needsUpdate = true;
}
/**
* Get current floor plan calibration state.
* @returns {Object} Current calibration data
*/
function getFloorPlanCalibration() {
return _floorCalibration;
}
// ── 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 })
);
}
// Store MAC in userData for spatial quick actions raycasting
m.userData = m.userData || {};
m.userData.mac = n.mac;
_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 Fresnel zones if visible
if (_fresnelZonesVisible) {
rebuildActiveFresnelZones();
}
}
/**
* 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);
canvas.addEventListener('click', _onBlobClick);
// Close context menus on click elsewhere
document.addEventListener('click', function() {
_hideBlobContextMenu();
_hideNodeContextMenu();
});
}
/**
* 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 =
'
' +
'
Track #' + blobId + '
' +
'
' +
' ' +
' ' +
' ' +
'
' +
'
';
_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();
}
// ── Context Menu (Blobs & Nodes) ────────────────────────────────────────────
let _blobContextMenu = null;
let _nodeContextMenu = null;
/**
* Handle context menu (right-click) on blobs and nodes.
*/
function _onBlobContextMenu(event) {
event.preventDefault();
if (!_camera || !_scene) 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;
_raycaster.setFromCamera(_mouse, _camera);
// Check for blob intersection first
if (_blobs3D.size > 0) {
var blobMeshes = [];
_blobs3D.forEach(function(obj) {
if (obj.group) {
blobMeshes.push(obj.group);
}
});
var blobIntersects = _raycaster.intersectObjects(blobMeshes, true);
if (blobIntersects.length > 0) {
// Find the blob object from the intersected mesh
var intersected = blobIntersects[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);
return;
}
}
}
// Check for node intersection
if (_nodeMeshes.size > 0) {
var nodeMeshes = Array.from(_nodeMeshes.values());
var nodeIntersects = _raycaster.intersectObjects(nodeMeshes, true);
if (nodeIntersects.length > 0) {
var intersectedNode = nodeIntersects[0].object;
var nodeMAC = null;
// Walk up the parent chain to find the node mesh/group
var current = intersectedNode;
while (current && !nodeMAC) {
_nodeMeshes.forEach(function(mesh, mac) {
if (mesh === current) {
nodeMAC = mac;
}
});
if (nodeMAC) break;
current = current.parent;
}
if (nodeMAC) {
_showNodeContextMenu(event, nodeMAC);
return;
}
}
}
}
/**
* 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 =
'' +
'' +
'' +
'';
// 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;
}
}
/**
* Show context menu for a node.
*/
function _showNodeContextMenu(event, nodeMAC) {
// Remove existing context menus
_hideBlobContextMenu();
_hideNodeContextMenu();
// Create context menu element
var menu = document.createElement('div');
menu.className = 'blob-context-menu';
menu.innerHTML =
'';
// Position menu at cursor
menu.style.left = event.clientX + 'px';
menu.style.top = event.clientY + 'px';
document.body.appendChild(menu);
_nodeContextMenu = 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 node context menu.
*/
function _hideNodeContextMenu() {
if (_nodeContextMenu) {
document.body.removeChild(_nodeContextMenu);
_nodeContextMenu = null;
}
}
/**
* Handle click on blobs for explainability.
* Opens the explainability view when a blob figure is clicked.
*/
function _onBlobClick(event) {
if (!_camera || !_scene) return;
// Don't trigger if right-click (context menu)
if (event.button === 2) 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) {
// Open explainability view
explainBlob(blobObj.blobId);
}
}
}
/**
* Identify a node by blinking its LED.
* @param {string} mac - The MAC address of the node to identify
*/
function identifyNode(mac) {
_hideNodeContextMenu();
if (window.identifyNode) {
window.identifyNode(mac);
} else {
console.error('[Viz3D] identifyNode function not available');
}
}
/**
* 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);' +
'}' +
'.feedback-why {' +
' background: rgba(76, 195, 247, 0.3);' +
'}' +
'.feedback-why:hover {' +
' background: rgba(76, 195, 247, 0.5);' +
'}';
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); }
}
function handleZoneChange(msg) {
var zone = msg.zone;
if (!zone) return;
if (msg.action === 'deleted') {
var existing = _zoneMeshes.get(zone.id);
if (existing) {
_scene.remove(existing.mesh);
_scene.remove(existing.label);
_scene.remove(existing.occupantsLabel);
existing.mesh.geometry.dispose();
existing.mesh.material.dispose();
_zoneMeshes.delete(zone.id);
}
_currentZones.delete(zone.id);
} else {
_currentZones.set(zone.id, zone);
var existing = _zoneMeshes.get(zone.id);
if (!existing) {
var zoneMesh = _createZoneMesh(zone);
_zoneMeshes.set(zone.id, zoneMesh);
} else {
// Update existing zone
existing.occupantsLabel.visible = zone.count > 0;
if (zone.count > 0) {
var peopleText = zone.people && zone.people.length > 0 ? zone.people.join(', ') : zone.count;
_updateTextSprite(existing.occupantsLabel, zone.name + ': ' + peopleText);
}
}
}
}
function handlePortalChange(msg) {
var portal = msg.portal;
if (!portal) return;
if (msg.action === 'deleted') {
var existing = _portalMeshes.get(portal.id);
if (existing) {
_scene.remove(existing.mesh);
_scene.remove(existing.label);
existing.mesh.geometry.dispose();
existing.mesh.material.dispose();
_portalMeshes.delete(portal.id);
}
_currentPortals.delete(portal.id);
} else {
_currentPortals.set(portal.id, portal);
var existing = _portalMeshes.get(portal.id);
if (!existing) {
var portalMesh = _createPortalMesh(portal);
_portalMeshes.set(portal.id, portalMesh);
}
}
}
function handleZoneOccupancy(msg) {
var zones = msg.zones || [];
zones.forEach(function(zoneOcc) {
var zoneMesh = _zoneMeshes.get(zoneOcc.id);
if (zoneMesh && zoneOcc.count > 0) {
zoneMesh.occupantsLabel.visible = true;
var zone = _currentZones.get(zoneOcc.id);
var zoneName = zone ? zone.name : zoneOcc.id;
_updateTextSprite(zoneMesh.occupantsLabel, zoneName + ': ' + zoneOcc.count);
}
});
}
function handleZoneTransition(msg) {
// Flash the portal to indicate crossing
if (msg.portal_id) {
flashPortal(msg.portal_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 = null;
var now = new Date();
if (_flowTimeFilter === '7d') {
since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
} else if (_flowTimeFilter === '30d') {
since = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
}
var url = '/api/analytics/flow';
var params = [];
if (since) {
params.push('since=' + encodeURIComponent(since.toISOString()));
}
params.push('until=' + encodeURIComponent(now.toISOString()));
if (_flowPersonFilter) {
params.push('person_id=' + encodeURIComponent(_flowPersonFilter));
}
if (params.length > 0) {
url += '?' + params.join('&');
}
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.cell_size_m || 0.25;
_flowData.cells.forEach(function(cell) {
// Note: API returns grid_x, grid_y where Y in floor plan = Z in 3D
var cx = (cell.grid_x + 0.5) * gridSize;
var cz = (cell.grid_y + 0.5) * gridSize;
// Direction vector: API returns vx, vy where Y in floor plan = Z in 3D
var dir = new THREE.Vector3(cell.vx, 0, cell.vy).normalize();
var magnitude = Math.sqrt(cell.vx * cell.vx + cell.vy * cell.vy);
var length = Math.min(magnitude * 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 = _dwellData.cell_size_m || 0.25;
_dwellData.cells.forEach(function(cell) {
// Note: API returns grid_x, grid_y where Y in floor plan = Z in 3D
var cx = (cell.grid_x + 0.5) * gridSize;
var cz = (cell.grid_y + 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
// Note: API returns centroid_xyz as [x, y, z] and dominant_direction_xy as [x, y]
var length = corridor.length_m;
var width = corridor.width_m;
var centroid = corridor.centroid_xyz || [0, 0, 0];
var cx = centroid[0];
var cz = centroid[2]; // Z in 3D space
// Compute rotation from dominant direction (x, y in floor plan -> x, z in 3D)
var direction = corridor.dominant_direction_xy || [1, 0];
var angle = Math.atan2(direction[1], direction[0]);
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
};
}
/**
* Set flow data directly (used by crowdflow.js module).
* @param {Object} data - Flow map data from API
*/
function setFlowData(data) {
_flowData = data;
rebuildFlowArrows();
}
/**
* Set dwell heatmap data directly (used by crowdflow.js module).
* @param {Object} data - Dwell heatmap data from API
*/
function setDwellData(data) {
_dwellData = data;
rebuildDwellPlanes();
}
/**
* Set corridor data directly (used by crowdflow.js module).
* @param {Array} data - Corridor data from API
*/
function setCorridorData(data) {
_corridorData = data;
rebuildCorridorMeshes();
}
// ── 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;
});
}
// ── GDOP Overlay Visualization ───────────────────────────────────────────────────
// GDOP overlay state
let _gdopOverlayVisible = false;
let _gdopMesh = null; // THREE.Mesh with GDOP texture
let _gdopTexture = null; // THREE.DataTexture with GDOP data
let _gdopData = null; // Cached GDOP heatmap data
let _gdopLegendVisible = false;
let _gdopLegendSprites = []; // Array of THREE.Sprite for legend
/**
* Set visibility of GDOP overlay layer.
* @param {boolean} visible - Whether to show GDOP overlay
*/
function setGDOPOverlayVisible(visible) {
_gdopOverlayVisible = visible;
if (_gdopMesh) {
_gdopMesh.visible = visible;
}
if (_gdopLegendVisible) {
_gdopLegendSprites.forEach(function(sprite) {
sprite.visible = visible;
});
}
if (visible && !_gdopData) {
fetchGDOPData();
}
}
/**
* Fetch GDOP heatmap data from API.
*/
function fetchGDOPData() {
fetch('/api/simulator/gdop/heatmap')
.then(function(response) { return response.json(); })
.then(function(data) {
_gdopData = data;
updateGDOPOverlay(data);
})
.catch(function(err) {
console.error('[Viz3D] Failed to fetch GDOP data:', err);
});
}
/**
* Update the GDOP overlay with new data.
* @param {Object} data - GDOP computation results
*/
function updateGDOPOverlay(data) {
if (!data || !data.gdop_map) {
console.warn('[Viz3D] No GDOP heatmap data in response');
return;
}
var gridDimensions = data.grid_dimensions || [0, 0, 0];
var width = gridDimensions[0];
var depth = gridDimensions[1];
var cellSize = 0.2; // Default cell size (not provided by this endpoint)
// Create texture from GDOP data
var gdopValues = new Float32Array(data.gdop_map);
// Generate colors from GDOP values using GDOPColorMap
var colors = new Uint8Array(width * depth * 3);
for (var i = 0; i < gdopValues.length; i++) {
var gdop = gdopValues[i];
var color;
if (gdop >= 9999) {
color = { r: 80, g: 80, b: 80 }; // None - gray
} else if (gdop < 2.0) {
color = { r: 34, g: 197, b: 94 }; // Excellent - green
} else if (gdop < 4.0) {
color = { r: 255, g: 193, b: 7 }; // Good - yellow
} else if (gdop < 8.0) {
color = { r: 255, g: 146, b: 0 }; // Fair - orange
} else {
color = { r: 220, g: 53, b: 69 }; // Poor - red
}
colors[i * 3] = color.r;
colors[i * 3 + 1] = color.g;
colors[i * 3 + 2] = color.b;
}
// Create data texture
if (_gdopTexture) {
_gdopTexture.dispose();
}
_gdopTexture = new THREE.DataTexture(colors, width, depth, THREE.RGBFormat);
_gdopTexture.needsUpdate = true;
// Create or update mesh
if (!_gdopMesh) {
var geo = new THREE.PlaneGeometry(
width * cellSize,
depth * cellSize
);
var mat = new THREE.MeshBasicMaterial({
map: _gdopTexture,
transparent: true,
opacity: 0.6,
side: THREE.DoubleSide,
depthWrite: false
});
_gdopMesh = new THREE.Mesh(geo, mat);
_gdopMesh.rotation.x = -Math.PI / 2;
_gdopMesh.position.set(
(width * cellSize) / 2,
0.01, // Slightly above floor
(depth * cellSize) / 2
);
_scene.add(_gdopMesh);
_gdopMesh.visible = _gdopOverlayVisible;
} else {
// Update existing mesh dimensions
_gdopMesh.geometry.dispose();
_gdopMesh.geometry = new THREE.PlaneGeometry(
width * cellSize,
depth * cellSize
);
_gdopMesh.position.set(
(width * cellSize) / 2,
0.01,
(depth * cellSize) / 2
);
_gdopMesh.material.map = _gdopTexture;
}
// Update or create legend
updateGDOPLegend(data.coverage_percent || data.coverage_score);
console.log('[Viz3D] GDOP overlay updated:', (data.coverage_percent || data.coverage_score || 0).toFixed(1) + '% coverage');
}
/**
* Update or create the GDOP legend.
* @param {number} coverageScore - Coverage percentage (0-100)
*/
function updateGDOPLegend(coverageScore) {
// Clear existing legend sprites
_gdopLegendSprites.forEach(function(sprite) {
_scene.remove(sprite);
});
_gdopLegendSprites = [];
if (!_gdopOverlayVisible) {
return;
}
// Create legend sprites
var legendItems = [
{ color: [34, 197, 94], label: 'Excellent', gdop: '< 2' },
{ color: [255, 193, 7], label: 'Good', gdop: '2-4' },
{ color: [255, 146, 0], label: 'Fair', gdop: '4-8' },
{ color: [220, 53, 69], label: 'Poor', gdop: '> 8' },
{ color: [80, 80, 80], label: 'None', gdop: '∞' }
];
var startY = 1.5;
var spacing = 0.15;
legendItems.forEach(function(item, index) {
var canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 64;
var ctx = canvas.getContext('2d');
// Draw color box
ctx.fillStyle = 'rgb(' + item.color.join(',') + ')';
ctx.fillRect(10, 16, 32, 32);
// Draw border
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.lineWidth = 2;
ctx.strokeRect(10, 16, 32, 32);
// Draw label
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 24px Arial, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(item.label + ' (GDOP ' + item.gdop + ')', 50, 32);
// Create texture
var texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
// Create sprite
var material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false
});
var sprite = new THREE.Sprite(material);
sprite.scale.set(1.5, 0.4, 1);
sprite.position.set(
(_room ? (_room.origin_x || 0) + _room.width + 0.5 : 6),
startY - index * spacing,
(_room ? (_room.origin_z || 0) + _room.depth / 2 : 2.5)
);
_scene.add(sprite);
_gdopLegendSprites.push(sprite);
});
// Add coverage score sprite
var scoreCanvas = document.createElement('canvas');
scoreCanvas.width = 256;
scoreCanvas.height = 64;
var scoreCtx = scoreCanvas.getContext('2d');
scoreCtx.fillStyle = '#ffffff';
scoreCtx.font = 'bold 28px Arial, sans-serif';
scoreCtx.textAlign = 'center';
scoreCtx.textBaseline = 'middle';
scoreCtx.fillText('Coverage: ' + coverageScore.toFixed(1) + '%', 128, 32);
var scoreTexture = new THREE.CanvasTexture(scoreCanvas);
scoreTexture.needsUpdate = true;
var scoreSprite = new THREE.Sprite(
new THREE.SpriteMaterial({
map: scoreTexture,
transparent: true,
depthTest: false
})
);
scoreSprite.scale.set(2, 0.5, 1);
scoreSprite.position.set(
(_room ? (_room.origin_x || 0) + _room.width + 0.5 : 6),
startY - legendItems.length * spacing - 0.2,
(_room ? (_room.origin_z || 0) + _room.depth / 2 : 2.5)
);
_scene.add(scoreSprite);
_gdopLegendSprites.push(scoreSprite);
_gdopLegendVisible = true;
}
/**
* Clear the GDOP overlay.
*/
function clearGDOPOverlay() {
if (_gdopMesh) {
_scene.remove(_gdopMesh);
_gdopMesh.geometry.dispose();
_gdopMesh.material.dispose();
_gdopMesh = null;
}
if (_gdopTexture) {
_gdopTexture.dispose();
_gdopTexture = null;
}
_gdopData = null;
}
/**
* Get current GDOP overlay state.
* @returns {Object} State object
*/
function getGDOPState() {
return {
visible: _gdopOverlayVisible,
hasData: _gdopData !== null,
coverageScore: _gdopData ? _gdopData.coverage_score : null
};
}
/**
* Get the Three.js scene (for external modules like simulator).
* @returns {THREE.Scene} The scene object
*/
function getScene() {
return _scene;
}
/**
* 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();
}
/**
* Fly the camera to focus on a specific node.
* @param {string} mac - Node MAC address
*/
function flyToNode(mac) {
var nodeMesh = _nodeMeshes.get(mac);
if (!nodeMesh) {
console.warn('[Viz3D] Node mesh not found for MAC:', mac);
return;
}
var pos = nodeMesh.position;
focusOnPosition(pos.x, pos.y, pos.z);
}
/**
* Clear all anomaly zone overlays.
*/
function clearAnomalyZones() {
_anomalyMeshes.forEach(function(mesh) {
_scene.remove(mesh);
mesh.geometry.dispose();
mesh.material.dispose();
});
_anomalyMeshes.clear();
_anomalyZones = [];
}
// ── Zone and Portal Rendering ───────────────────────────────────────────────
/**
* Create a zone mesh as a semi-transparent colored cuboid.
* @param {Object} zone - Zone data with id, name, x, y, z, w, d, h, color
* @returns {Object} { mesh, label, occupantsLabel }
*/
function _createZoneMesh(zone) {
var geometry = new THREE.BoxGeometry(zone.w || 4, zone.h || 2.5, zone.d || 3);
var color = zone.color ? parseInt(zone.color.replace('#', '0x')) : 0x3b82f6;
var material = new THREE.MeshLambertMaterial({
color: color,
transparent: true,
opacity: 0.1,
side: THREE.DoubleSide,
depthWrite: false
});
var mesh = new THREE.Mesh(geometry, material);
mesh.position.set(
(zone.x || 0) + (zone.w || 4) / 2,
(zone.y || 0) + (zone.h || 2.5) / 2,
(zone.z || 0) + (zone.d || 3) / 2
);
mesh.userData.zoneId = zone.id;
mesh.renderOrder = -1; // Render before other objects
_scene.add(mesh);
// Create zone label (floating text at zone centroid)
var label = _createTextSprite(zone.name || zone.id, color);
var cx = (zone.x || 0) + (zone.w || 4) / 2;
var cy = (zone.y || 0) + (zone.h || 2.5) / 2;
var cz = (zone.z || 0) + (zone.d || 3) / 2;
label.position.set(cx, cy + 0.5, cz);
_scene.add(label);
// Create occupants label (initially empty)
var occupantsLabel = _createTextSprite('', color);
occupantsLabel.position.set(cx, cy + 0.2, cz);
occupantsLabel.visible = false;
_scene.add(occupantsLabel);
return { mesh: mesh, label: label, occupantsLabel: occupantsLabel };
}
/**
* Create a portal mesh as a thin vertical plane.
* @param {Object} portal - Portal data with id, name, p1_x, p1_y, p1_z, p2_x, p2_y, p2_z, width, height
* @returns {Object} { mesh, label, flashEndTime }
*/
function _createPortalMesh(portal) {
var p1 = new THREE.Vector3(portal.p1_x || 0, portal.p1_y || 0, portal.p1_z || 0);
var p2 = new THREE.Vector3(portal.p2_x || 0, portal.p2_y || 0, portal.p2_z || 0);
var width = portal.width || 1.0;
var height = portal.height || 2.1;
// Calculate portal center
var center = new THREE.Vector3().addVectors(p1, p2).multiplyScalar(0.5);
// Create plane geometry (width x height)
var geometry = new THREE.PlaneGeometry(width, height);
var material = new THREE.MeshLambertMaterial({
color: 0xa855f7, // Purple
transparent: true,
opacity: 0.3,
side: THREE.DoubleSide,
depthWrite: false
});
var mesh = new THREE.Mesh(geometry, material);
mesh.position.copy(center);
// Calculate orientation (perpendicular to floor, facing along portal normal)
// For a vertical plane defined by two floor points, we need the horizontal direction
var dx = p2.x - p1.x;
var dz = p2.z - p1.z;
var angle = Math.atan2(dz, dx);
mesh.rotation.y = angle + Math.PI / 2;
mesh.userData.portalId = portal.id;
mesh.renderOrder = -1;
_scene.add(mesh);
// Create portal label at top edge
var label = _createTextSprite(portal.name || portal.id, '#a855f7');
label.position.set(center.x, center.y + height / 2 + 0.3, center.z);
_scene.add(label);
return { mesh: mesh, label: label, flashEndTime: 0 };
}
/**
* Update zones from the snapshot data.
* @param {Array} zones - Array of zone objects from snapshot
*/
function updateZones(zones) {
if (!zones) return;
var zoneIDs = new Set();
zones.forEach(function(zone) {
zoneIDs.add(zone.id);
_currentZones.set(zone.id, zone);
var existing = _zoneMeshes.get(zone.id);
if (!existing) {
// Create new zone mesh
var zoneMesh = _createZoneMesh(zone);
_zoneMeshes.set(zone.id, zoneMesh);
} else {
// Update existing zone
existing.occupantsLabel.visible = zone.count > 0;
if (zone.count > 0) {
var peopleText = zone.people && zone.people.length > 0 ? zone.people.join(', ') : zone.count;
_updateTextSprite(existing.occupantsLabel, zone.name + ': ' + peopleText);
}
}
});
// Remove zones that no longer exist
_zoneMeshes.forEach(function(zoneMesh, zoneID) {
if (!zoneIDs.has(zoneID)) {
_scene.remove(zoneMesh.mesh);
_scene.remove(zoneMesh.label);
_scene.remove(zoneMesh.occupantsLabel);
zoneMesh.mesh.geometry.dispose();
zoneMesh.mesh.material.dispose();
_zoneMeshes.delete(zoneID);
}
});
}
/**
* Update portals from the snapshot data.
* @param {Array} portals - Array of portal objects from snapshot
*/
function updatePortals(portals) {
if (!portals) return;
var portalIDs = new Set();
portals.forEach(function(portal) {
portalIDs.add(portal.id);
_currentPortals.set(portal.id, portal);
var existing = _portalMeshes.get(portal.id);
if (!existing) {
// Create new portal mesh
var portalMesh = _createPortalMesh(portal);
_portalMeshes.set(portal.id, portalMesh);
}
});
// Remove portals that no longer exist
_portalMeshes.forEach(function(portalMesh, portalID) {
if (!portalIDs.has(portalID)) {
_scene.remove(portalMesh.mesh);
_scene.remove(portalMesh.label);
portalMesh.mesh.geometry.dispose();
portalMesh.mesh.material.dispose();
_portalMeshes.delete(portalID);
}
});
}
/**
* Flash a portal to indicate a crossing event.
* @param {string} portalId - The portal ID to flash
*/
function flashPortal(portalId) {
var portalMesh = _portalMeshes.get(portalId);
if (!portalMesh) return;
// Set flash end time (1 second from now)
portalMesh.flashEndTime = Date.now() + 1000;
}
/**
* Update portal flash animations.
* Called from the main update loop.
* @param {number} dt - Delta time in seconds
*/
function updatePortalFlashes(dt) {
var now = Date.now();
_portalMeshes.forEach(function(portalMesh, portalId) {
if (portalMesh.flashEndTime > now) {
// Flash animation: increase opacity
var progress = (portalMesh.flashEndTime - now) / 1000; // 1 to 0
portalMesh.mesh.material.opacity = 0.3 + progress * 0.7;
} else if (portalMesh.mesh.material.opacity !== 0.3) {
portalMesh.mesh.material.opacity = 0.3;
}
});
}
/**
* Update the text content of a sprite label.
* @param {THREE.Sprite} sprite - The sprite to update
* @param {string} text - New text content
*/
function _updateTextSprite(sprite, text) {
if (!sprite || !sprite.material || !sprite.material.map) return;
var canvas = sprite.material.map.image;
var ctx = canvas.getContext('2d');
var color = '#4fc3f7'; // Default color
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw background
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
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.roundRect(4, 4, canvas.width - 8, canvas.height - 8, 8);
ctx.stroke();
// Draw text
ctx.fillStyle = color;
ctx.font = 'bold 28px Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, canvas.width / 2, canvas.height / 2);
sprite.material.map.needsUpdate = true;
}
/**
* Toggle zones visibility.
* @param {boolean} visible - Whether to show zones
*/
function toggleZonesVisible(visible) {
_zonesVisible = visible !== undefined ? visible : !_zonesVisible;
_zoneMeshes.forEach(function(zoneMesh) {
zoneMesh.mesh.visible = _zonesVisible;
zoneMesh.label.visible = _zonesVisible;
zoneMesh.occupantsLabel.visible = _zonesVisible && zoneMesh.occupantsLabel.visible;
});
}
/**
* Toggle portals visibility.
* @param {boolean} visible - Whether to show portals
*/
function togglePortalsVisible(visible) {
_portalsVisible = visible !== undefined ? visible : !_portalsVisible;
_portalMeshes.forEach(function(portalMesh) {
portalMesh.mesh.visible = _portalsVisible;
portalMesh.label.visible = _portalsVisible;
});
}
// ── 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
wavelength: 0.123, // WiFi wavelength at 2.437 GHz (c/f in meters)
maxZones: 5 // Maximum number of Fresnel zones to visualize
};
let _fresnelZones = []; // Array of THREE.Mesh for explainability Fresnel zones
let _fresnelActiveZones = []; // Array of THREE.Line for active link Fresnel zones (wireframe)
let _fresnelZonesVisible = false; // Toggle state for active link Fresnel zones
/**
* Calculate Fresnel zone ellipsoid geometry for a link.
* @param {THREE.Vector3} tx - Transmitter position
* @param {THREE.Vector3} rx - Receiver position
* @param {number} zoneNumber - Fresnel zone number (1-based)
* @returns {Object} Ellipsoid parameters: { center, semiAxes, rotation }
*/
function _calculateFresnelZone(tx, rx, zoneNumber) {
// WiFi wavelength and Fresnel zone constants
const lambda = FRESNEL_CONFIG.wavelength; // ~0.123 m for 2.4 GHz
const n = zoneNumber;
// Direct distance between TX and RX
const d = tx.distanceTo(rx);
// Fresnel zone path difference: n * lambda / 2
const deltaL = n * lambda / 2;
// Ellipsoid semi-axes calculation
// For a prolate spheroid with foci at tx and rx:
// Semi-major axis (a) = (d + deltaL) / 2
// Semi-minor axis (b) = sqrt(deltaL * (d + deltaL)) / 2
const a = (d + deltaL) / 2;
const b = Math.sqrt(deltaL * (d + deltaL)) / 2;
// Center of ellipsoid (midpoint between TX and RX)
const center = new THREE.Vector3().addVectors(tx, rx).multiplyScalar(0.5);
// Rotation: align with TX-RX axis
const direction = new THREE.Vector3().subVectors(rx, tx).normalize();
const up = new THREE.Vector3(0, 1, 0);
const quaternion = new THREE.Quaternion().setFromUnitVectors(up, direction);
return {
center: center,
semiAxes: new THREE.Vector3(b, b, a), // X, Y, Z semi-axes (Z is along link axis)
rotation: quaternion,
zoneNumber: n
};
}
/**
* 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 = [];
}
// ── Active Link Fresnel Zone Visualization (Wireframe) ────────────────────────
/**
* Create a wireframe Fresnel zone ellipsoid for an active link.
* @param {THREE.Vector3} tx - Transmitter position
* @param {THREE.Vector3} rx - Receiver position
* @param {number} zoneNumber - Fresnel zone number (1-5)
* @param {number} color - Color hex value
* @returns {THREE.LineSegments|null} The created wireframe mesh
*/
function _createWireframeFresnelZone(tx, rx, zoneNumber, color) {
if (!_scene) return null;
// Calculate Fresnel zone geometry
var zone = _calculateFresnelZone(tx, rx, zoneNumber);
if (!zone) return null;
// Create wireframe ellipsoid using TorusGeometry (thin tube)
// Torus with tube radius ~0.005m, following the ellipsoid path
var tubeRadius = 0.008; // 8mm tube thickness for visibility
var tubularSegments = 64;
var radialSegments = 8;
var geometry = new THREE.TorusGeometry(
zone.semiAxes.z, // major radius (distance from center to ellipsoid surface along Z axis)
tubeRadius,
tubularSegments,
radialSegments
);
// Apply scaling to create ellipsoid instead of torus
// Scale X and Y by semi-minor / semi-major ratio
var scaleRatio = zone.semiAxes.x / zone.semiAxes.z;
geometry.scale(scaleRatio, scaleRatio, 1.0);
// Position and rotate
var mesh = new THREE.Mesh(geometry);
// Rotate to align with link direction
mesh.position.copy(zone.center);
mesh.quaternion.copy(zone.rotation);
// Orient the torus: rotate 90 degrees so tube lies in correct plane
var orientQuat = new THREE.Quaternion().setFromAxisAngle(
new THREE.Vector3(1, 0, 0),
Math.PI / 2
);
mesh.quaternion.multiply(orientQuat);
// Create wireframe material
var material = new THREE.LineBasicMaterial({
color: color || FRESNEL_CONFIG.color,
transparent: true,
opacity: 0.4,
depthTest: false
});
// Convert mesh to wireframe
var wireframe = new THREE.LineSegments(
new THREE.WireframeGeometry(geometry),
material
);
wireframe.position.copy(mesh.position);
wireframe.quaternion.copy(mesh.quaternion);
// Clean up temporary mesh
mesh.geometry.dispose();
_scene.add(wireframe);
return wireframe;
}
/**
* Rebuild Fresnel zone visualization for all active links.
* Creates wireframe ellipsoids for the first 3 Fresnel zones of each active link.
*/
function rebuildActiveFresnelZones() {
// Clear existing Fresnel zones
clearActiveFresnelZones();
if (!_fresnelZonesVisible) return;
// Get active links
_activeLinks.forEach(function(link, linkID) {
var txMesh = _nodeMeshes.get(link.node_mac);
var rxMesh = _nodeMeshes.get(link.peer_mac);
if (!txMesh || !rxMesh) return;
var tx = txMesh.position;
var rx = rxMesh.position;
// Determine color based on link health
var healthData = _linkHealth.get(linkID);
var healthScore = healthData ? healthData.score : 0.5;
var zoneColor = _getHealthColor(healthScore);
// Create Fresnel zones for first 3 zones
for (var n = 1; n <= 3; n++) {
var wireframe = _createWireframeFresnelZone(tx, rx, n, zoneColor);
if (wireframe) {
_fresnelActiveZones.push(wireframe);
wireframe.userData = {
linkID: linkID,
zoneNumber: n
};
}
}
});
}
/**
* Clear all active Fresnel zone wireframes.
*/
function clearActiveFresnelZones() {
_fresnelActiveZones.forEach(function(wireframe) {
if (_scene) {
_scene.remove(wireframe);
}
wireframe.geometry.dispose();
wireframe.material.dispose();
});
_fresnelActiveZones = [];
}
/**
* Toggle visibility of Fresnel zone overlays for active links.
* @param {boolean} visible - Whether to show Fresnel zones
*/
function toggleFresnelZones(visible) {
_fresnelZonesVisible = visible;
if (visible) {
rebuildActiveFresnelZones();
} else {
clearActiveFresnelZones();
}
}
// ── 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;
}
// ── Follow Camera ───────────────────────────────────────────────────────────
/**
* Set camera to follow a specific blob.
* @param {number} blobId - The blob ID to follow, or null to stop following
*/
function setFollowTarget(blobId) {
if (blobId === null || blobId === undefined) {
_followId = null;
return;
}
// Check if blob exists
if (!_blobs3D.has(blobId)) {
console.warn('[Viz3D] Cannot follow blob', blobId, '- not found');
return;
}
_followId = blobId;
console.log('[Viz3D] Now following blob', blobId);
// Show indicator
_showFollowIndicator(blobId);
}
/**
* Get the current follow target.
* @returns {number|null} The blob ID being followed, or null
*/
function getFollowTarget() {
return _followId;
}
/**
* Show follow mode indicator in UI.
*/
function _showFollowIndicator(blobId) {
// Remove existing indicator
_removeFollowIndicator();
// Create indicator
const indicator = document.createElement('div');
indicator.className = 'follow-mode-indicator';
indicator.id = 'follow-indicator';
// Get blob info
const blob = _blobs3D.get(blobId);
const personName = blob && blob.personLabel ? blob.personLabel : 'Blob #' + blobId;
indicator.textContent = 'Following ' + personName;
indicator.style.cursor = 'pointer';
indicator.style.pointerEvents = 'auto';
// Click to stop following
indicator.addEventListener('click', function() {
setFollowTarget(null);
_removeFollowIndicator();
});
document.body.appendChild(indicator);
// Auto-hide after 5 seconds
setTimeout(function() {
_removeFollowIndicator();
}, 5000);
}
/**
* Remove follow mode indicator.
*/
function _removeFollowIndicator() {
const indicator = document.getElementById('follow-indicator');
if (indicator) {
indicator.remove();
}
}
// ── Node Link Highlighting ─────────────────────────────────────────────────────
/**
* Highlight all links connected to a specific node.
* @param {string} mac - The node MAC address
* @param {boolean} highlight - Whether to highlight (true) or restore (false)
* @param {number} color - Optional color hex value (default: 0x4fc3f7)
*/
function highlightNodeLinks(mac, highlight, color) {
if (!_linkLines || _linkLines.size === 0) return;
const highlightColor = color || 0x4fc3f7;
_linkLines.forEach(function(line, linkID) {
// Check if this link involves the specified node
if (linkID.includes(mac)) {
if (highlight) {
// Store original material state
if (!line.userData.originalState) {
line.userData.originalState = {
opacity: line.material.opacity,
transparent: line.material.transparent,
color: line.material.color ? line.material.color.getHex() : null
};
// Apply highlight
line.material.opacity = 1.0;
line.material.transparent = false;
if (line.material.color) {
line.material.color.setHex(highlightColor);
}
if (line.material.emissive) {
line.material.emissive.setHex(highlightColor);
line.material.emissiveIntensity = 0.5;
}
line.material.needsUpdate = true;
} else {
// Restore original state
if (line.userData.originalState) {
const orig = line.userData.originalState;
line.material.opacity = orig.opacity;
line.material.transparent = orig.transparent;
if (line.material.color && orig.color !== null) {
line.material.color.setHex(orig.color);
}
if (line.material.emissive) {
line.material.emissiveIntensity = 0;
}
line.material.needsUpdate = true;
delete line.userData.originalState;
}
}
}
});
}
/**
* Clear all link highlights.
*/
function clearLinkHighlights() {
if (!_linkLines) return;
_linkLines.forEach(function(line) {
if (line.userData.originalState) {
const orig = line.userData.originalState;
line.material.opacity = orig.opacity;
line.material.transparent = orig.transparent;
if (line.material.color && orig.color !== null) {
line.material.color.setHex(orig.color);
}
if (line.material.emissive) {
line.material.emissiveIntensity = 0;
}
line.material.needsUpdate = true;
delete line.userData.originalState;
}
});
}
// ── Public API ────────────────────────────────────────────────────────────
return {
init,
update,
handleRegistryState,
handleLocUpdate,
handleLinkActive,
handleLinkInactive,
applyLinks,
uploadFloorPlan,
setFloorPlanCalibration,
getFloorPlanCalibration,
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,
setFlowData: setFlowData,
setDwellData: setDwellData,
setCorridorData: setCorridorData,
// 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,
flyToNode: flyToNode,
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,
toggleFresnelZones: toggleFresnelZones,
// Blob explainability
explainBlob: explainBlob,
// Node identification
identifyNode: identifyNode,
// Replay mode support
enterReplayMode: enterReplayMode,
exitReplayMode: exitReplayMode,
updateReplayBlobs: updateReplayBlobs,
// GDOP overlay support
setGDOPOverlayVisible: setGDOPOverlayVisible,
clearGDOPOverlay: clearGDOPOverlay,
getGDOPState: getGDOPState,
// Follow camera API
setFollowTarget: setFollowTarget,
getFollowTarget: getFollowTarget,
// Node link highlighting API
highlightNodeLinks: highlightNodeLinks,
clearLinkHighlights: clearLinkHighlights,
// Scene and controls access
scene: function() { return _scene; },
camera: function() { return _camera; },
controls: function() { return _controls; },
renderer: function() { return _renderer; },
blobMeshes: function() {
const meshes = [];
_blobs3D.forEach(function(obj) {
meshes.push(obj.group);
});
return meshes;
},
nodeMeshes: function() { return Array.from(_nodeMeshes.values()); },
// Zone and portal update handlers for WebSocket messages
handleZoneUpdate: function(zones) {
updateZones(zones);
},
handlePortalUpdate: function(portals) {
updatePortals(portals);
},
// Zone and portal change handlers for REST API changes
handleZoneChange: handleZoneChange,
handlePortalChange: handlePortalChange,
handleZoneOccupancy: handleZoneOccupancy,
handleZoneTransition: handleZoneTransition,
flashPortal: flashPortal,
toggleZonesVisible: toggleZonesVisible,
togglePortalsVisible: togglePortalsVisible,
};
// ── Replay Mode Support ─────────────────────────────────────────────────────
// Store live blob states for replay mode restoration
let _liveBlobStates = new Map();
let _isReplayMode = false;
/**
* Enter replay mode: store current blob states and prepare for replay visualization
*/
function enterReplayMode() {
if (_isReplayMode) return;
_isReplayMode = true;
// Store current blob states for restoration
_liveBlobStates.clear();
_blobs3D.forEach(function(obj, blobId) {
_liveBlobStates.set(blobId, {
id: blobId,
x: obj.lastPosition ? obj.lastPosition.x : 0,
y: obj.lastPosition ? obj.lastPosition.y : 1.3,
z: obj.lastPosition ? obj.lastPosition.z : 0,
vx: obj.lastVelocity ? obj.lastVelocity.vx : 0,
vy: obj.lastVelocity ? obj.lastVelocity.vy : 0,
vz: obj.lastVelocity ? obj.lastVelocity.vz : 0,
weight: obj.weight || 0.5,
posture: obj.posture || 'unknown',
personId: obj.personId || null,
personLabel: obj.personLabel || null,
personColor: obj.personColor || null,
trail: obj.trail ? obj.trail.slice() : []
});
});
console.log('[Viz3D] Replay mode entered, stored', _liveBlobStates.size, 'blob states');
}
/**
* Exit replay mode: restore live blob states
*/
function exitReplayMode() {
if (!_isReplayMode) return;
_isReplayMode = false;
// Clear all replay blobs
_blobs3D.forEach(function(obj, blobId) {
_removeBlobObj(blobId, obj);
});
_blobs3D.clear();
// Restore live blob states
var liveBlobs = [];
_liveBlobStates.forEach(function(state) {
liveBlobs.push({
id: state.id,
x: state.x,
y: state.y,
z: state.z,
vx: state.vx,
vy: state.vy,
vz: state.vz,
weight: state.weight,
posture: state.posture,
person_id: state.personId,
person_label: state.personLabel,
person_color: state.personColor
});
});
if (liveBlobs.length > 0) {
applyLocUpdate(liveBlobs);
}
_liveBlobStates.clear();
console.log('[Viz3D] Replay mode exited, restored', liveBlobs.length, 'blob states');
}
/**
* Update blobs during replay mode
* @param {Array} blobs - Array of blob updates from replay worker
* @param {number} timestampMS - Replay timestamp in milliseconds
*/
function updateReplayBlobs(blobs, timestampMS) {
if (!_isReplayMode) {
console.warn('[Viz3D] updateReplayBlobs called but not in replay mode');
return;
}
// Clear current blobs
_blobs3D.forEach(function(obj, blobId) {
_removeBlobObj(blobId, obj);
});
_blobs3D.clear();
// Add replay blobs
if (blobs && blobs.length > 0) {
var blobUpdates = blobs.map(function(b) {
return {
id: b.id,
x: b.x,
y: b.y,
z: b.z,
vx: b.vx,
vy: b.vy,
vz: b.vz,
weight: b.weight,
posture: b.posture,
person_id: b.person_id,
person_label: b.person_label,
person_color: b.person_color,
trail: b.trail
};
});
applyLocUpdate(blobUpdates);
}
}
// ── Public API ───────────────────────────────────────────────────────────────
return {
// Core
init: init,
update: update,
// Room
applyRoom: applyRoom,
clearRoom: clearRoom,
// Nodes
applyNodeList: applyNodeList,
updateNodePositions: updateNodePositions,
getNodeMeshes: function() { return _nodeMeshes; },
nodeMeshes: function() { return _nodeMeshes; }, // alias for quick-actions
// Links
applyLinkList: applyLinkList,
updateLinkHealth: updateLinkHealth,
highlightNodeLinks: highlightNodeLinks,
clearLinkHighlights: clearLinkHighlights,
// Blobs
applyLocUpdate: applyLocUpdate,
getBlobs3D: function() { return _blobs3D; },
// View presets
setViewPreset: setViewPreset,
resetView: resetView,
// Ghost node
setGhostNode: setGhostNode,
clearGhostNode: clearGhostNode,
// Replay
loadReplaySnapshot: loadReplaySnapshot,
// Follow camera mode
setFollowTarget: setFollowTarget,
getFollowTarget: getFollowTarget,
// Spatial quick actions support
portalMeshes: function() {
const meshes = [];
_portalMeshes.forEach(function(portalMesh) {
meshes.push(portalMesh.mesh);
});
return meshes;
},
triggerMeshes: function() {
// Get trigger volume meshes from VolumeEditor if available
if (window.VolumeEditor && window.VolumeEditor.getVolumeMeshes) {
return window.VolumeEditor.getVolumeMeshes();
}
return [];
},
// Direct access (for advanced integrations)
scene: function() { return _scene; },
camera: function() { return _camera; },
controls: function() { return _controls; },
followId: function() { return _followId; },
// Crowd Flow Visualization
setFlowLayerVisible: setFlowLayerVisible,
setDwellLayerVisible: setDwellLayerVisible,
setCorridorLayerVisible: setCorridorLayerVisible,
setFlowTimeFilter: setFlowTimeFilter,
setFlowData: setFlowData,
setDwellData: setDwellData,
setCorridorData: setCorridorData
};
})();
// ── Global wrapper functions for HTML event handlers ─────────────────────────────
/**
* Toggle flow layer visibility (called from HTML checkbox).
* @param {boolean} visible - Whether to show the layer
*/
function toggleFlowLayer(visible) {
if (window.Viz3D) {
window.Viz3D.setFlowLayerVisible(visible);
}
}
/**
* Toggle dwell heatmap layer visibility (called from HTML checkbox).
* @param {boolean} visible - Whether to show the layer
*/
function toggleDwellLayer(visible) {
if (window.Viz3D) {
window.Viz3D.setDwellLayerVisible(visible);
}
}
/**
* Toggle corridor overlay layer visibility (called from HTML checkbox).
* @param {boolean} visible - Whether to show the layer
*/
function toggleCorridorLayer(visible) {
if (window.Viz3D) {
window.Viz3D.setCorridorLayerVisible(visible);
}
}
/**
* Set time filter for flow data (called from HTML select).
* @param {string} timeFilter - '7d', '30d', or 'all'
*/
function setFlowTimeFilter(timeFilter) {
if (window.Viz3D) {
window.Viz3D.setFlowTimeFilter(timeFilter);
}
}