/** * Spaxel Anomaly Detection UI * * Handles: alarm overlay, acknowledgement flow, feedback form, * zone pulsing indication, and anomaly history display. */ (function() { 'use strict'; // ── module state ────────────────────────────────────────────────────────── let _activeAnomalies = []; // Unacknowledged anomalies let _anomalyHistory = []; // Recent anomaly history let _learningProgress = 0; // 0.0 - 1.0 let _modelReady = false; let _securityMode = false; // DOM elements (lazy-initialized) let _overlayEl = null; let _bannerEl = null; let _feedbackModalEl = null; // Callbacks let _onAcknowledge = null; let _onViewIn3D = null; // ── initialization ──────────────────────────────────────────────────────── function init() { // Create overlay elements if not present ensureOverlayElements(); ensureFeedbackModal(); // Start polling for anomaly status startPolling(); console.log('[Anomaly] Module initialized'); } function ensureOverlayElements() { // Check if overlay already exists _overlayEl = document.getElementById('anomaly-overlay'); if (_overlayEl) { _bannerEl = document.getElementById('anomaly-banner'); return; } // Create overlay structure const overlay = document.createElement('div'); overlay.id = 'anomaly-overlay'; overlay.className = 'anomaly-overlay hidden'; overlay.innerHTML = `
`; document.body.appendChild(overlay); _overlayEl = overlay; _bannerEl = document.getElementById('anomaly-banner'); // Add event listeners document.getElementById('anomaly-ack-btn').addEventListener('click', handleAcknowledge); document.getElementById('anomaly-view-btn').addEventListener('click', handleViewIn3D); document.getElementById('anomaly-dismiss-btn').addEventListener('click', handleDismiss); } function ensureFeedbackModal() { _feedbackModalEl = document.getElementById('anomaly-feedback-modal'); if (_feedbackModalEl) return; const modal = document.createElement('div'); modal.id = 'anomaly-feedback-modal'; modal.className = 'anomaly-feedback-modal hidden'; modal.innerHTML = ` `; document.body.appendChild(modal); _feedbackModalEl = modal; // Event listeners modal.querySelector('.modal-backdrop').addEventListener('click', hideFeedbackModal); document.getElementById('feedback-cancel-btn').addEventListener('click', hideFeedbackModal); document.getElementById('feedback-submit-btn').addEventListener('click', submitFeedback); // Option selection modal.querySelectorAll('.feedback-btn').forEach(btn => { btn.addEventListener('click', function() { modal.querySelectorAll('.feedback-btn').forEach(b => b.classList.remove('selected')); this.classList.add('selected'); document.getElementById('feedback-submit-btn').disabled = false; }); }); } // ── polling ──────────────────────────────────────────────────────────────── function startPolling() { fetchAnomalyStatus(); setInterval(fetchAnomalyStatus, 5000); // Poll every 5 seconds } function fetchAnomalyStatus() { fetch('/api/anomalies/active') .then(res => res.json()) .then(anomalies => { handleAnomalyUpdate(anomalies || []); }) .catch(err => console.error('[Anomaly] Failed to fetch status:', err)); fetch('/api/anomalies/learning') .then(res => res.json()) .then(data => { _learningProgress = data.progress || 0; _modelReady = data.model_ready || false; updateLearningBanner(); }) .catch(err => console.error('[Anomaly] Failed to fetch learning status:', err)); } // ── anomaly handling ──────────────────────────────────────────────────────── function handleAnomalyUpdate(anomalies) { const prevCount = _activeAnomalies.length; _activeAnomalies = anomalies; if (anomalies.length > 0) { // Show overlay with first unacknowledged anomaly showAnomalyOverlay(anomalies[0]); } else if (prevCount > 0) { // All anomalies acknowledged/cleared hideAnomalyOverlay(); } // Update zone pulsing in 3D view if (window.Viz3D && Viz3D.setAnomalyZones) { const zoneIDs = anomalies.map(a => a.zone_id).filter(z => z); Viz3D.setAnomalyZones(zoneIDs); } } function showAnomalyOverlay(anomaly) { if (!_overlayEl) ensureOverlayElements(); const descEl = document.getElementById('anomaly-description'); const metaEl = document.getElementById('anomaly-meta'); descEl.textContent = anomaly.description || 'Unknown anomaly detected'; // Format metadata let meta = ''; if (anomaly.zone_name) meta += `Zone: ${anomaly.zone_name} `; if (anomaly.person_name) meta += `Person: ${anomaly.person_name} `; if (anomaly.score) meta += `Score: ${(anomaly.score * 100).toFixed(0)}% `; if (anomaly.timestamp) { const ts = new Date(anomaly.timestamp); meta += `Time: ${ts.toLocaleTimeString()}`; } metaEl.textContent = meta; _overlayEl.classList.remove('hidden'); _overlayEl.dataset.anomalyId = anomaly.id; } function hideAnomalyOverlay() { if (_overlayEl) { _overlayEl.classList.add('hidden'); } } function handleAcknowledge() { if (!_overlayEl || !_overlayEl.dataset.anomalyId) return; const anomalyId = _overlayEl.dataset.anomalyId; const anomaly = _activeAnomalies.find(a => a.id === anomalyId); if (!anomaly) return; // Show feedback modal showFeedbackModal(anomaly); } function handleViewIn3D() { if (!_overlayEl || !_overlayEl.dataset.anomalyId) return; const anomalyId = _overlayEl.dataset.anomalyId; const anomaly = _activeAnomalies.find(a => a.id === anomalyId); if (!anomaly) return; if (_onViewIn3D) { _onViewIn3D(anomaly); } else if (window.Viz3D && Viz3D.focusOnZone && anomaly.zone_id) { Viz3D.focusOnZone(anomaly.zone_id); } else if (window.Viz3D && Viz3D.focusOnPosition && anomaly.position) { Viz3D.focusOnPosition(anomaly.position.x, anomaly.position.y, anomaly.position.z); } } function handleDismiss() { // Dismiss just hides the overlay, doesn't acknowledge hideAnomalyOverlay(); // Show next anomaly if any if (_activeAnomalies.length > 1) { setTimeout(() => { if (_activeAnomalies.length > 0) { showAnomalyOverlay(_activeAnomalies[0]); } }, 1000); } } // ── feedback modal ────────────────────────────────────────────────────────── let _currentFeedbackAnomaly = null; function showFeedbackModal(anomaly) { _currentFeedbackAnomaly = anomaly; const descEl = document.getElementById('feedback-anomaly-desc'); descEl.textContent = anomaly.description || 'Unknown anomaly'; // Reset selection _feedbackModalEl.querySelectorAll('.feedback-btn').forEach(b => b.classList.remove('selected')); document.getElementById('feedback-notes-input').value = ''; document.getElementById('feedback-submit-btn').disabled = true; _feedbackModalEl.classList.remove('hidden'); } function hideFeedbackModal() { if (_feedbackModalEl) { _feedbackModalEl.classList.add('hidden'); } _currentFeedbackAnomaly = null; } function submitFeedback() { if (!_currentFeedbackAnomaly) return; const selectedBtn = _feedbackModalEl.querySelector('.feedback-btn.selected'); if (!selectedBtn) return; const feedback = selectedBtn.dataset.feedback; const notes = document.getElementById('feedback-notes-input').value; const anomalyId = _currentFeedbackAnomaly.id; fetch(`/api/anomalies/${anomalyId}/acknowledge`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ feedback: feedback, notes: notes, acknowledged_by: 'dashboard_user' }) }) .then(res => res.json()) .then(data => { console.log('[Anomaly] Feedback submitted:', data); hideFeedbackModal(); hideAnomalyOverlay(); // Show toast if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Feedback recorded. Thank you!', 'success'); } // Refresh anomaly list fetchAnomalyStatus(); }) .catch(err => { console.error('[Anomaly] Failed to submit feedback:', err); if (window.SpaxelApp && SpaxelApp.showToast) { SpaxelApp.showToast('Failed to submit feedback', 'error'); } }); } // ── learning banner ───────────────────────────────────────────────────────── function updateLearningBanner() { const banner = document.getElementById('anomaly-learning-banner'); if (!banner) return; if (_modelReady) { banner.classList.add('hidden'); return; } banner.classList.remove('hidden'); const progressEl = banner.querySelector('.learning-progress'); const daysEl = banner.querySelector('.days-remaining'); if (progressEl) { progressEl.style.width = (_learningProgress * 100) + '%'; } if (daysEl) { const daysLeft = Math.ceil((1 - _learningProgress) * 7); daysEl.textContent = daysLeft > 0 ? `${daysLeft} days remaining` : 'Almost ready...'; } } // ── WebSocket message handling ─────────────────────────────────────────────── function handleWebSocketMessage(msg) { switch (msg.type) { case 'anomaly_detected': // New anomaly detected if (msg.data) { _activeAnomalies.unshift(msg.data); showAnomalyOverlay(msg.data); // Play alert sound in security mode if (_securityMode) { playAlertSound(); } } break; case 'system_mode_change': if (msg.data && msg.data.new_mode) { _securityMode = msg.data.new_mode === 'away'; updateSecurityModeIndicator(); } break; case 'anomaly_cleared': // Anomaly was acknowledged/cleared if (msg.data && msg.data.id) { _activeAnomalies = _activeAnomalies.filter(a => a.id !== msg.data.id); if (_activeAnomalies.length > 0) { showAnomalyOverlay(_activeAnomalies[0]); } else { hideAnomalyOverlay(); } } break; } } function updateSecurityModeIndicator() { const indicator = document.getElementById('security-mode-indicator'); if (!indicator) return; if (_securityMode) { indicator.classList.add('active'); indicator.textContent = 'SECURITY MODE'; } else { indicator.classList.remove('active'); indicator.textContent = ''; } } function playAlertSound() { // Create a simple alert tone try { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = audioCtx.createOscillator(); const gainNode = audioCtx.createGain(); oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(880, audioCtx.currentTime); oscillator.frequency.setValueAtTime(660, audioCtx.currentTime + 0.1); oscillator.frequency.setValueAtTime(880, audioCtx.currentTime + 0.2); gainNode.gain.setValueAtTime(0.3, audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.5); oscillator.start(audioCtx.currentTime); oscillator.stop(audioCtx.currentTime + 0.5); } catch (e) { console.warn('[Anomaly] Could not play alert sound:', e); } } // ── public API ────────────────────────────────────────────────────────────── window.AnomalyUI = { init: init, handleWebSocketMessage: handleWebSocketMessage, getActiveAnomalies: function() { return _activeAnomalies; }, isSecurityMode: function() { return _securityMode; }, isModelReady: function() { return _modelReady; }, getLearningProgress: function() { return _learningProgress; }, setOnAcknowledge: function(cb) { _onAcknowledge = cb; }, setOnViewIn3D: function(cb) { _onViewIn3D = cb; }, showAnomalyOverlay: showAnomalyOverlay, hideAnomalyOverlay: hideAnomalyOverlay, refresh: fetchAnomalyStatus }; })();