/** * 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 = '
' + ' 😎' + ' Why is this here?' + '
' + '
' + '
' + ' 👍' + ' Correct detection' + '
' + '
' + ' 👎' + ' Incorrect detection' + '
'; // 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 = '
' + ' ' + ' Identify (blink LED)' + '
'; // 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); } }