/**
* Spaxel Detection Explainability Module
*
* Provides the "Why is this here?" feature that explains blob detections by:
* - Showing per-link contribution breakdown
* - Highlighting contributing links in the 3D view
* - Rendering Fresnel zone ellipsoids
* - Displaying BLE match information
*
* @module Explainability
*/
const Explainability = (function () {
'use strict';
// ── State ───────────────────────────────────────────────────────────────
let _isActive = false;
let _currentBlobID = null;
let _explanationData = null;
let _originalMaterialStates = new Map(); // obj.uuid -> {opacity, emissive, transparent}
let _highlightedLinks = [];
let _fresnelMeshes = [];
let _sidebarPanel = null;
let _sceneOverlay = null;
let _viz3dCallbacks = []; // Store Viz3D cleanup callbacks
let _escKeyHandler = null;
// ── Configuration ─────────────────────────────────────────────────────────
const CONFIG = {
dimOpacity: 0.2, // Opacity for dimmed elements
highlightColor: 0xFFD700, // Gold for contributing links
highlightEmissive: 0x555500,
fresnelOpacity: 0.25, // Opacity for Fresnel zones
fresnelColor: 0x4FC3F7, // Blue for Fresnel zones
animationDuration: 300, // ms for scene transitions
};
// ── UI Components ─────────────────────────────────────────────────────────
function createSidebarPanel() {
var panel = document.createElement('div');
panel.id = 'explainability-sidebar';
panel.className = 'panel-sidebar panel-sidebar-right';
panel.style.display = 'none';
panel.innerHTML =
'
' +
'
Why is this here?
' +
' ' +
'
' +
'
' +
'
' +
' ' +
' Loading explanation...' +
'
' +
'
';
document.body.appendChild(panel);
// Create overlay backdrop
var backdrop = document.createElement('div');
backdrop.id = 'explainability-backdrop';
backdrop.className = 'panel-overlay';
backdrop.style.display = 'none';
backdrop.addEventListener('click', function() {
Explainability.close();
});
document.body.appendChild(backdrop);
_sidebarPanel = panel;
_sceneOverlay = backdrop;
return panel;
}
function renderContent(data) {
var content = document.getElementById('explainability-content');
if (!content) return;
if (!data) {
content.innerHTML =
'
' +
'
📁
' +
'
No explanation data available
' +
'
';
return;
}
// Calculate confidence percentage
var confidencePercent = Math.round(data.confidence * 100);
var html = '';
// Blob position display
if (data.blob_position) {
html += '
';
}
// BLE match section
if (data.ble_match) {
var ble = data.ble_match;
var bleName = ble.person_label || ble.person_id || 'Unknown';
var bleConfidence = ble.confidence || ble.triangulation_confidence || 0;
var bleDevice = ble.device_addr || ble.device_mac || '';
var bleColor = ble.person_color || '#4488ff';
html += '
';
} else if (data.all_links && data.all_links.length > 0) {
// No BLE match: show "Unknown" identity
html += '
' +
'
Identity
' +
'
' +
'
' +
' ' +
' Unknown' +
' no BLE device match' +
'
' +
'
' +
'
';
}
// Close button at bottom
html += '';
content.innerHTML = html;
}
function _shortenMAC(mac) {
// Shorten MAC to last 6 characters
if (!mac) return '----';
if (mac.length <= 8) return mac;
return '...' + mac.slice(-8);
}
function _getZoneColor(zoneNumber) {
// Color gradient for Fresnel zones
var colors = [
'#22c55e', // zone 1 - green
'#84cc16', // zone 2 - lime
'#cddc39', // zone 3 - yellow
'#ffeb3b', // zone 4 - orange
'#ff9800', // zone 5 - deep orange
];
return colors[Math.min(zoneNumber - 1, colors.length - 1)] || '#999';
}
/**
* Draw a deltaRMS sparkline on a canvas element.
* The right edge represents the detection moment.
* A horizontal dashed line shows the motion threshold.
*
* @param {HTMLCanvasElement} canvas
* @param {number[]} points - deltaRMS values over 30 s (oldest first)
* @param {number} threshold - motion detection threshold (default 0.02)
*/
function _drawSparkline(canvas, points, threshold) {
var ctx = canvas.getContext('2d');
var w = canvas.width;
var h = canvas.height;
threshold = threshold || 0.02;
ctx.clearRect(0, 0, w, h);
// Background
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, w, h);
var maxVal = threshold * 2;
if (points && points.length > 0) {
for (var i = 0; i < points.length; i++) {
if (points[i] > maxVal) maxVal = points[i];
}
}
maxVal = maxVal || 0.1;
// Threshold dashed line
var threshY = h - (threshold / maxVal) * (h - 6) - 3;
ctx.save();
ctx.setLineDash([3, 3]);
ctx.strokeStyle = '#ff6b6b';
ctx.lineWidth = 1;
ctx.globalAlpha = 0.7;
ctx.beginPath();
ctx.moveTo(0, threshY);
ctx.lineTo(w, threshY);
ctx.stroke();
ctx.restore();
if (!points || points.length < 2) {
// Single value: draw a flat line at current level
var curVal = (points && points.length === 1) ? points[0] : 0;
var flatY = h - (curVal / maxVal) * (h - 6) - 3;
ctx.strokeStyle = '#4FC3F7';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, flatY);
ctx.lineTo(w - 2, flatY);
ctx.stroke();
} else {
var xStep = (w - 2) / (points.length - 1);
// Fill area under sparkline
ctx.fillStyle = 'rgba(79, 195, 247, 0.12)';
ctx.beginPath();
for (var i = 0; i < points.length; i++) {
var x = i * xStep;
var y = h - (points[i] / maxVal) * (h - 6) - 3;
if (i === 0) ctx.moveTo(x, h);
ctx.lineTo(x, y);
}
ctx.lineTo((points.length - 1) * xStep, h);
ctx.closePath();
ctx.fill();
// Sparkline
ctx.strokeStyle = '#4FC3F7';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (var i = 0; i < points.length; i++) {
var x = i * xStep;
var y = h - (points[i] / maxVal) * (h - 6) - 3;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}
// Detection marker at right edge
ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(w - 1, 0);
ctx.lineTo(w - 1, h);
ctx.stroke();
}
/**
* Fetch 30-second deltaRMS history for each contributing link and draw sparklines.
* Falls back gracefully if the recordings API is unavailable.
*
* @param {Array} contributingLinks - Array of link contribution objects
*/
function _fetchAndDrawSparklines(contributingLinks) {
if (!contributingLinks || contributingLinks.length === 0) return;
contributingLinks.forEach(function (link) {
var safeID = 'sparkline-' + link.link_id.replace(/[^a-zA-Z0-9]/g, '_');
var canvas = document.getElementById(safeID);
if (!canvas) return;
var deltaRMS = link.delta_rms || 0;
// Try to fetch 30s history from the recordings API
fetch('/api/recordings/' + encodeURIComponent(link.link_id) + '/recent?seconds=30')
.then(function (resp) {
if (!resp.ok) throw new Error('no data');
return resp.json();
})
.then(function (data) {
var points = Array.isArray(data.delta_rms) ? data.delta_rms : [deltaRMS];
_drawSparkline(canvas, points, 0.02);
})
.catch(function () {
// Fallback: render a flat line at the current deltaRMS value
_drawSparkline(canvas, [deltaRMS], 0.02);
});
});
}
function toggleSection(header) {
var content = header.nextElementSibling;
var icon = header.querySelector('.toggle-icon');
var parentSection = header.parentElement;
if (content.style.display === 'none') {
content.style.display = 'block';
if (icon) icon.textContent = '▲';
parentSection.classList.remove('collapsed');
} else {
content.style.display = 'none';
if (icon) icon.textContent = '▼';
parentSection.classList.add('collapsed');
}
}
// ── 3D Scene Manipulation ─────────────────────────────────────────────────
/**
* Apply the X-ray overlay effect to the 3D scene.
* Dims non-contributing elements and highlights contributing links.
*/
function applyXRayOverlay(explanationData) {
if (!window.Viz3D) return;
// Save original material states and apply dimming
_saveAndDimScene();
// Highlight contributing links
_highlightContributingLinks(explanationData);
// Render Fresnel zone ellipsoids
_renderFresnelZones(explanationData);
}
/**
* Save original material states and dim the scene.
*/
function _saveAndDimScene() {
_originalMaterialStates.clear();
// Dim room elements using Viz3D callback
if (window.Viz3D.forEachRoomObject) {
Viz3D.forEachRoomObject(function(obj) {
_dimObject(obj);
});
}
// Dim all links first (will highlight contributing ones later)
if (window.Viz3D.forEachLink) {
Viz3D.forEachLink(function(line, linkID) {
_dimObject(line);
});
}
// Dim non-target blobs
if (window.Viz3D.forEachBlob) {
Viz3D.forEachBlob(function(obj, blobID) {
if (blobID !== _currentBlobID && obj.group) {
_dimObject(obj.group);
}
});
}
}
function _dimObject(obj) {
if (!obj || !obj.material) return;
var uuid = obj.uuid;
if (!uuid) return;
// Save original state
if (!_originalMaterialStates.has(uuid)) {
_originalMaterialStates.set(uuid, {
opacity: obj.material.opacity,
transparent: obj.material.transparent,
emissiveIntensity: obj.material.emissiveIntensity || 0
});
if (obj.material.emissive) {
_originalMaterialStates.get(uuid).emissiveColor = obj.material.emissive.getHex();
}
if (obj.material.color) {
_originalMaterialStates.get(uuid).color = obj.material.color.getHex();
}
}
// Apply dimming
obj.material.opacity = CONFIG.dimOpacity;
obj.material.transparent = true;
if (obj.material.emissive) {
obj.material.emissive.setHex(0x000000);
obj.material.emissiveIntensity = 0;
}
obj.material.needsUpdate = true;
}
/**
* Highlight contributing links with a glowing effect.
*/
function _highlightContributingLinks(explanationData) {
if (!explanationData.contributing_links || !window.Viz3D) return;
explanationData.contributing_links.forEach(function (link) {
if (window.Viz3D.highlightLink) {
Viz3D.highlightLink(link.link_id, CONFIG.highlightColor, CONFIG.highlightEmissive, 0.8);
_highlightedLinks.push(link.link_id);
}
});
}
/**
* Map a contribution percentage (0-100) to a Fresnel zone color.
* Low contribution = pale blue, high contribution = bright yellow.
*/
function _contributionColor(contributionPct) {
// Normalize to 0..1 range
var t = Math.min(1, Math.max(0, (contributionPct || 0) / 50));
// Interpolate from pale blue (0x4FC3F7) to bright yellow (0xFFD700)
var r = Math.round(0x4F + t * (0xFF - 0x4F));
var g = Math.round(0xC3 + t * (0xD7 - 0xC3));
var b = Math.round(0xF7 + t * (0x00 - 0xF7));
return (r << 16) | (g << 8) | b;
}
/**
* Render Fresnel zone ellipsoids for contributing links.
* Uses the Fresnel module for proper orientation along the link axis.
*/
function _renderFresnelZones(explanationData) {
if (!explanationData.fresnel_zones) return;
// Build a lookup of contribution percentages by link_id for color mapping
var contribMap = {};
if (explanationData.contributing_links) {
explanationData.contributing_links.forEach(function (link) {
contribMap[link.link_id] = (link.contribution || 0) * 100;
});
}
explanationData.fresnel_zones.forEach(function (zone) {
var pct = contribMap[zone.link_id] || 0;
var color = _contributionColor(pct);
// Try the Fresnel module first (proper orientation along link axis)
if (window.Fresnel && zone.tx_pos && zone.rx_pos) {
var tx = new THREE.Vector3(zone.tx_pos[0], zone.tx_pos[1], zone.tx_pos[2]);
var rx = new THREE.Vector3(zone.rx_pos[0], zone.rx_pos[1], zone.rx_pos[2]);
// Derive channel from wavelength: 2.4 GHz channels ≤ 14, 5 GHz > 14
var channel = zone.lambda <= 0.07 ? 36 : 6;
var ellipsoid = Fresnel.addFresnelEllipsoid(tx, rx, channel, color, {
fillOpacity: 0.1,
wireframeOpacity: 0.4
});
if (ellipsoid) {
_fresnelMeshes.push(ellipsoid);
}
return;
}
// Fallback to Viz3D (no orientation, but at least shows ellipsoid at center)
if (window.Viz3D && Viz3D.addFresnelZone) {
var mesh = Viz3D.addFresnelZone(
zone.center_pos[0], zone.center_pos[1], zone.center_pos[2],
zone.semi_axes[0], zone.semi_axes[1], zone.semi_axes[2],
color,
CONFIG.fresnelOpacity
);
if (mesh) {
_fresnelMeshes.push(mesh);
}
}
});
}
/**
* Restore the scene to its original state.
*/
function restoreScene() {
// Restore material states
if (window.Viz3D) {
_originalMaterialStates.forEach(function (state, uuid) {
if (window.Viz3D.restoreObjectMaterial) {
Viz3D.restoreObjectMaterial(uuid, state);
}
});
}
// Remove Fresnel zone ellipsoids
_fresnelMeshes.forEach(function (item) {
// Fresnel module returns { wireframe, fill, data } objects
if (item && item.wireframe && window.Fresnel) {
Fresnel.removeFresnelEllipsoid(item);
} else if (item && window.Viz3D) {
Viz3D.removeFresnelZone(item);
}
});
_fresnelMeshes = [];
_highlightedLinks = [];
_originalMaterialStates.clear();
}
// ── API Integration ─────────────────────────────────────────────────────────
function fetchExplanation(blobID) {
// Send a WebSocket request_explain message. The server will respond
// with a blob_explain message on the next fusion tick, which app.js
// routes to handleExplainSnapshot().
if (window.SpaxelWebSocket && SpaxelWebSocket.send) {
SpaxelWebSocket.send(JSON.stringify({
type: 'request_explain',
blob_id: blobID
}));
} else {
// Fallback to REST API if WebSocket not available
fetch('/api/explain/' + encodeURIComponent(blobID))
.then(function (response) {
if (!response.ok) throw new Error('Failed to fetch explanation');
return response.json();
})
.then(function (data) {
_explanationData = data;
renderContent(data);
applyXRayOverlay(data);
})
.catch(function (error) {
console.error('[Explainability] Failed to load explanation:', error);
renderContent(null);
});
}
}
/**
* Handle a blob_explain WebSocket message from the fusion engine.
* Called by app.js when a {"type":"blob_explain",...} message arrives.
*
* @param {number} blobID - The blob ID this snapshot explains
* @param {Object} snapshot - The ExplainabilitySnapshot from the server
*/
function handleExplainSnapshot(blobID, snapshot) {
// Only process if we're waiting for this blob
if (!_isActive || _currentBlobID !== blobID) return;
_explanationData = _transformSnapshot(snapshot);
renderContent(_explanationData);
applyXRayOverlay(_explanationData);
}
/**
* Transform the WebSocket ExplainabilitySnapshot into the format
* expected by renderContent() and the X-ray overlay.
*/
function _transformSnapshot(snap) {
if (!snap) return null;
var data = {
confidence: snap.fusion_score || 0,
blob_position: snap.blob_position,
contributing_links: [],
all_links: [],
fresnel_zones: [],
ble_match: snap.ble_match || null
};
if (snap.per_link_contributions) {
snap.per_link_contributions.forEach(function (link) {
var entry = {
link_id: link.link_id,
node_mac: link.tx_mac,
peer_mac: link.rx_mac,
delta_rms: link.delta_rms,
zone_number: link.zone_number,
weight: link.combined_weight,
contribution: link.contribution_pct / 100,
contributing: link.contributing
};
if (link.contributing) {
data.contributing_links.push(entry);
}
data.all_links.push(entry);
});
}
// Sort contributing by contribution descending
data.contributing_links.sort(function (a, b) {
return b.contribution - a.contribution;
});
// Pass Fresnel zone geometry from the server snapshot
if (snap.fresnel_zones) {
data.fresnel_zones = snap.fresnel_zones;
}
return data;
}
// ── Public API ─────────────────────────────────────────────────────────────
return {
/**
* Open the explainability view for a blob.
* @param {number} blobID - The blob ID to explain
*/
explain: function (blobID) {
if (_isActive) {
// Already open, just switch to new blob
close();
}
_isActive = true;
_currentBlobID = blobID;
// Create UI if needed
if (!_sidebarPanel) {
createSidebarPanel();
}
// Show sidebar
_sidebarPanel.style.display = 'block';
_sidebarPanel.classList.add('panel-sidebar-visible');
_sceneOverlay.style.display = 'block';
_sceneOverlay.classList.add('panel-overlay-visible');
// Fetch and display data
fetchExplanation(blobID);
// Register Escape key handler to exit explain mode
if (!_escKeyHandler) {
_escKeyHandler = function(e) {
if (e.key === 'Escape' && _isActive) {
Explainability.close();
}
};
document.addEventListener('keydown', _escKeyHandler);
}
},
/**
* Close the explainability view.
*/
close: function () {
if (!_isActive) return;
_isActive = false;
_currentBlobID = null;
_explanationData = null;
// Hide sidebar
if (_sidebarPanel) {
_sidebarPanel.classList.remove('panel-sidebar-visible');
_sidebarPanel.style.display = 'none';
}
if (_sceneOverlay) {
_sceneOverlay.classList.remove('panel-overlay-visible');
_sceneOverlay.style.display = 'none';
}
// Restore scene
restoreScene();
// Remove Escape key handler
if (_escKeyHandler) {
document.removeEventListener('keydown', _escKeyHandler);
_escKeyHandler = null;
}
},
/**
* Check if explainability view is currently active.
*/
isActive: function () {
return _isActive;
},
/**
* Toggle the all links section.
*/
toggleSection: toggleSection,
/**
* Get current explanation data.
*/
getData: function () {
return _explanationData;
},
/**
* Get current blob ID.
*/
getCurrentBlobID: function () {
return _currentBlobID;
},
/**
* Handle a blob_explain WebSocket message.
* Called by app.js when a blob_explain message arrives.
*/
handleExplainSnapshot: handleExplainSnapshot
};
})();
// Make toggleSection available globally for onclick
window.Explainability = Explainability;