/** * 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 += '
' + 'Detected at (' + data.blob_position[0].toFixed(1) + 'm, ' + '' + data.blob_position[1].toFixed(1) + 'm, ' + '' + data.blob_position[2].toFixed(1) + 'm)' + '
'; } html += '
' + '
' + ' ' + ' ' + ' ' + ' ' + '
' + confidencePercent + '%
' + '
' + ' Detection confidence' + '
'; // Contributing links table if (data.contributing_links && data.contributing_links.length > 0) { html += '
' + '

Contributing Links

' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' ' + ' '; data.contributing_links.forEach(function (link) { var zoneColor = _getZoneColor(link.zone_number); var contribPct = Math.round((link.contribution || 0) * 100); html += '' + ' ' + ' ' + ' ' + ' ' + ''; }); html += ' ' + ' ' + '
'; // Motion sparklines: 30-second deltaRMS history per contributing link html += '
' + '

Signal History (30s)

' + '
'; data.contributing_links.forEach(function (link) { var safeID = 'sparkline-' + link.link_id.replace(/[^a-zA-Z0-9]/g, '_'); html += '
' + ' ' + _shortenMAC(link.node_mac) + ':' + _shortenMAC(link.peer_mac) + '' + ' ' + ' ' + '
'; }); html += '
' + '
'; } // All links (including non-contributing) if (data.all_links && data.all_links.length > 0) { 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 += '
' + '

BLE Identity Match

' + '
' + '
' + ' ' + ' ' + bleName + '' + ' ' + Math.round(bleConfidence * 100) + '% confident' + '
' + '
' + '
' + ' Device:' + ' ' + bleDevice + '' + '
'; if (ble.ble_distance_m !== undefined) { html += '
' + ' Distance:' + ' ' + ble.ble_distance_m.toFixed(1) + 'm from blob' + '
'; } if (ble.reported_by_nodes && ble.reported_by_nodes.length > 0) { html += '
' + ' Seen by:' + ' ' + ble.reported_by_nodes.join(', ') + '' + '
'; } 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;