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