/** * Spaxel Dashboard - Main Application * * Phase 1 skeleton: 3D scene with ground grid, OrbitControls, * WebSocket connection, and amplitude bar chart visualization. */ (function() { 'use strict'; // ============================================ // Configuration // ============================================ const CONFIG = { gridWidth: 10, // meters gridDepth: 10, // meters gridDivisions: 20, cameraFov: 60, cameraNear: 0.1, cameraFar: 1000, cameraInitial: { x: 8, y: 8, z: 8 }, chartBars: 64, // number of subcarriers chartUpdateMs: 100, // update chart at 10 Hz max tsMaxPoints: 360, // max time series samples per link (~60s at 6Hz) tsMinIntervalMs: 100, // min ms between time series samples drTsWindowMs: 10000, // deltaRMS time series window: 10 seconds drTsMaxPoints: 100, // max deltaRMS samples per link drThreshold: 0.02, // DefaultDeltaRMSThreshold healthPollIntervalMs: 10000, // poll /api/links every 10 seconds diurnalPollIntervalMs: 30000, // poll /api/diurnal/status every 30 seconds // Mobile performance settings // Frame rate capping for struggling mobile devices mobileFrameRateCap: 30, // Target FPS for mobile devices when struggling (30 = cap, 60 = uncapped) // Set to 60 to disable initial cap but keep auto-detection // Set to 30 to cap mobile devices from the start autoDetectStrugglingDevice: true, // Auto-detect and cap FPS on struggling mobile devices strugglingDeviceThreshold: 25, // FPS threshold below which a device is considered "struggling" strugglingDeviceRecoveryThreshold: 40 // FPS threshold to recover from struggling mode }; // ============================================ // State // ============================================ const state = { ws: null, wsConnected: false, awaitingSnapshot: false, // true between WS open and first snapshot message nodes: new Map(), // MAC -> { mac, firmware, chip, lastSeen } links: new Map(), // linkID -> { nodeMAC, peerMAC, lastFrame, lastCSI, motionDetected, deltaRMS, ampHistory, lastAmpSample } selectedLinkID: null, presenceSelectedLinkID: null, drHistory: new Map(), // linkID -> [{ t: number, rms: number }] lastChartUpdate: 0, frameCount: 0, lastFpsTime: performance.now(), // Frame rate capping for struggling devices targetFPS: 60, // Target frame rate (60 for desktop, 30 for struggling mobile) minFrameTime: 1000 / 60, // Minimum time between frames (16.67ms at 60fps) lastFrameTime: 0, // Time of last rendered frame fpsHistory: [], // Recent FPS samples for detecting struggling devices strugglingDevice: false, // Auto-detected low performance flag manualFrameRateCap: null // User-specified frame rate cap (null = auto-detect) // System health tracking systemHealth: 0, worstLinkID: null, worstLinkScore: 1.0, // Diurnal learning tracking diurnalStatus: new Map(), // linkID -> { is_learning, progress, is_ready, days_remaining } diurnalPollTimer: null, healthPollTimer: null, // BLE device tracking bleDevices: new Map(), // MAC -> { mac, name, rssi, last_seen, label, blob_id } // Alert tracking alerts: new Map(), // id -> { id, ts, severity, description, acknowledged } unacknowledgedCount: 0, // Event dedup: set of recently processed event IDs to avoid double-processing // from immediate broadcast + delta buffering recentEventIDs: new Set(), recentEventIDsPruneAt: 0, // Fresnel debug overlay state fresnelDebugVisible: false, fresnelEllipsoids: new Map(), // linkID -> { wireframe, fill, data } fresnelRaycaster: new THREE.Raycaster(), fresnelMouse: new THREE.Vector2(), fresnelHoveredEllipsoid: null }; // ============================================ // Three.js Scene Setup // ============================================ let scene, camera, renderer, controls, gridHelper, axesHelper, clock; /** * Detect if the current device is a mobile device based on screen width. * @returns {boolean} True if screen width < 1024px (mobile) */ function isMobile() { return window.innerWidth < 1024; } /** * Get the pixel ratio capped at 2.0 for mobile devices. * This prevents excessive rendering on high-DPI mobile displays. * @returns {number} The device pixel ratio, capped at 2.0 for mobile */ function getPixelRatio() { const rawRatio = window.devicePixelRatio || 1; if (isMobile()) { return Math.min(rawRatio, 2.0); } return rawRatio; } function initScene() { const container = document.getElementById('scene-container'); // Scene scene = new THREE.Scene(); scene.background = new THREE.Color(0x1a1a2e); // Camera camera = new THREE.PerspectiveCamera( CONFIG.cameraFov, window.innerWidth / window.innerHeight, CONFIG.cameraNear, CONFIG.cameraFar ); camera.position.set( CONFIG.cameraInitial.x, CONFIG.cameraInitial.y, CONFIG.cameraInitial.z ); // Renderer with mobile performance optimizations const mobile = isMobile(); const pixelRatio = getPixelRatio(); renderer = new THREE.WebGLRenderer({ antialias: !mobile // Disable MSAA on mobile to save GPU cycles }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(pixelRatio); // Disable shadow maps on mobile (or cap at 512x512 for very limited use) if (mobile) { renderer.shadowMap.enabled = false; } else { renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFSoftShadowMap; } container.appendChild(renderer.domElement); // Initialize frame rate cap based on CONFIG // On mobile, optionally cap frame rate to improve performance if (mobile) { const capFPS = CONFIG.mobileFrameRateCap; if (capFPS && capFPS < 60) { state.targetFPS = capFPS; state.minFrameTime = 1000 / capFPS; state.manualFrameRateCap = capFPS; console.log('[Spaxel] Mobile frame rate capped at ' + capFPS + ' FPS (config)'); } else { // Still enable auto-detection for mobile even if not initially capped console.log('[Spaxel] Mobile device: frame rate auto-detection enabled (cap at ' + (CONFIG.mobileFrameRateCap || 30) + ' FPS if struggling)'); } } // OrbitControls controls = new THREE.OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.screenSpacePanning = true; controls.minDistance = 2; controls.maxDistance = 50; controls.maxPolarAngle = Math.PI / 2 + 0.3; // Allow slight below-ground view // Touch-specific optimizations for mobile controls.rotateSpeed = 0.8; // Rotation speed controls.zoomSpeed = 1.0; // Zoom speed for pinch gesture (standard speed for accurate touch) controls.panSpeed = 0.8; // Pan speed for multi-finger drag controls.enablePan = true; // Enable pan (two-finger or three-finger drag) controls.enableZoom = true; // Enable zoom (pinch gesture) controls.enableRotate = true; // Enable rotate (one-finger drag) // Touch gesture configuration for OrbitControls // One finger: rotate (orbit) // Two fingers: pinch-to-zoom (dolly) only // Three fingers: pan (native OrbitControls support) // // Using OrbitControls' built-in touch configuration: // - ONE: THREE.TOUCH.ROTATE (one-finger orbit) // - TWO: THREE.TOUCH.DOLLY_PAN (pinch zoom + two-finger pan, with pan disabled below) // - THREE: THREE.TOUCH.PAN (three-finger pan) // // Note: We set enablePan=false initially to prevent two-finger pan, // allowing only pinch zoom on two fingers. Three-finger pan works // through the THREE.TOUCH.PAN configuration. controls.touches = { ONE: THREE.TOUCH.ROTATE, // One-finger touch rotates the camera TWO: THREE.TOUCH.DOLLY_PAN, // Two-finger touch zooms (pinch) and pans THREE: THREE.TOUCH.PAN // Three-finger touch pans the camera }; // Disable two-finger pan to prevent accidental pan during pinch zoom // Only allow three-finger pan for deliberate camera panning controls.listenToKeyEvents(window); // Enable keyboard controls for desktop // Custom touch event handling to prevent two-finger pan while allowing three-finger pan const element = renderer.domElement; let originalEnablePan = controls.enablePan; element.addEventListener('touchstart', function(event) { // Disable pan for two-finger touch to prevent accidental pan during pinch zoom if (event.touches.length === 2) { controls.enablePan = false; } else if (event.touches.length === 3) { // Enable pan for three-finger touch controls.enablePan = true; } else { controls.enablePan = originalEnablePan; } }, { passive: true }); element.addEventListener('touchend', function(event) { // Restore pan state when fingers are lifted if (event.touches.length === 0) { controls.enablePan = originalEnablePan; } else if (event.touches.length === 2) { controls.enablePan = false; } else if (event.touches.length === 3) { controls.enablePan = true; } }, { passive: true }); element.addEventListener('touchcancel', function() { // Restore pan state on touch cancel controls.enablePan = originalEnablePan; }, { passive: true }); // Grid helper (XZ plane, Y-up) gridHelper = new THREE.GridHelper( CONFIG.gridWidth, CONFIG.gridDivisions, 0x444466, // center line color 0x333344 // grid line color ); scene.add(gridHelper); // Axes helper for orientation axesHelper = new THREE.AxesHelper(2); axesHelper.position.set(-CONFIG.gridWidth / 2, 0.01, -CONFIG.gridDepth / 2); scene.add(axesHelper); // Ambient light const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); scene.add(ambientLight); // Directional light const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4); directionalLight.position.set(5, 10, 5); scene.add(directionalLight); // Handle window resize window.addEventListener('resize', onWindowResize); // Handle orientation change (mobile devices) window.addEventListener('orientationchange', onOrientationChange); // Handle visual viewport resize (iOS Safari specific) if (window.visualViewport) { window.visualViewport.addEventListener('resize', function() { // Throttle visual viewport resize events if (orientationChangeTimeout) { clearTimeout(orientationChangeTimeout); } orientationChangeTimeout = setTimeout(onWindowResize, 50); }); } // Clock for animation mixer delta clock = new THREE.Clock(); // Initialise 3-D spatial visualisation layer Viz3D.init(scene, camera, controls); // Initialise placement (TransformControls, GDOP, room editor) if (window.Placement) { Placement.init(scene, camera, renderer, controls); } // Initialise zone editor (TransformControls for zones) if (window.ZoneEditor) { ZoneEditor.init(scene, camera, renderer, controls); } // Initialise portal editor (TransformControls for portals) if (window.PortalEditor) { PortalEditor.init(scene, camera, renderer, controls); } // Initialize FXAA post-processing on mobile devices // FXAA is used instead of MSAA on mobile for better performance if (window.SpaxelFXAA) { window.SpaxelFXAA.init(scene, camera, renderer).then(function() { console.log('[Spaxel] FXAA initialization complete, active: ' + window.SpaxelFXAA.isActive()); }).catch(function(err) { console.error('[Spaxel] FXAA initialization failed:', err); }); } console.log('[Spaxel] Scene initialized'); } /** * Get the current viewport dimensions, preferring visualViewport API on iOS Safari. * Falls back to window.innerWidth/Height for browsers that don't support visualViewport. * @returns {Object} {width: number, height: number} Viewport dimensions in pixels */ function getViewportDimensions() { // Use visualViewport API on iOS Safari (handles address bar, bottom nav) if (window.visualViewport && window.visualViewport.width > 0) { return { width: window.visualViewport.width, height: window.visualViewport.height }; } // Fallback to standard window dimensions return { width: window.innerWidth, height: window.innerHeight }; } /** * Handle window resize events with iOS Safari visual viewport support. * Updates renderer size, camera aspect ratio, and projection matrix. */ function onWindowResize() { const dims = getViewportDimensions(); const width = dims.width; const height = dims.height; // Update camera aspect ratio camera.aspect = width / height; camera.updateProjectionMatrix(); // Update renderer size renderer.setSize(width, height); // Update pixel ratio in case it changed (e.g., moving between displays) // Use capped pixel ratio for mobile devices renderer.setPixelRatio(getPixelRatio()); // Update FXAA resolution if active if (window.SpaxelFXAA && window.SpaxelFXAA.isActive()) { window.SpaxelFXAA.updateResolution(); } } /** * Handle orientation change events. * Debounced to avoid multiple rapid calls during orientation transition. */ var orientationChangeTimeout = null; function onOrientationChange() { // Clear any pending resize if (orientationChangeTimeout) { clearTimeout(orientationChangeTimeout); } // Debounce resize to allow browser to complete orientation transition // iOS Safari visual viewport may take a moment to stabilize orientationChangeTimeout = setTimeout(function() { // Force a full resize after orientation change completes onWindowResize(); // Additional visual viewport check for iOS Safari if (window.visualViewport) { // iOS Safari: wait for visual viewport to stabilize // The resize event will fire again once it does window.visualViewport.addEventListener('resize', function onViewportResize() { onWindowResize(); // Remove listener after first callback window.visualViewport.removeEventListener('resize', onViewportResize); }, { once: true }); } }, 100); } function animate() { requestAnimationFrame(animate); const now = performance.now(); const elapsed = now - state.lastFrameTime; // Frame rate capping: skip this frame if not enough time has elapsed if (elapsed < state.minFrameTime) { return; // Skip this frame to maintain target FPS } state.lastFrameTime = now; controls.update(); Viz3D.update(); if (window.Placement) Placement.update(); // Use FXAA post-processing on mobile, direct rendering on desktop if (window.SpaxelFXAA && window.SpaxelFXAA.isActive()) { window.SpaxelFXAA.render(); } else { renderer.render(scene, camera); } updateFPS(); detectStrugglingDevice(); } /** * Detect struggling devices by monitoring FPS history. * If consistently below threshold FPS on mobile, cap at 30 FPS. * This helps devices that can't maintain 60 FPS by reducing rendering load. */ function detectStrugglingDevice() { if (!isMobile()) return; // Only cap frame rate on mobile // Calculate current FPS based on actual frame rendering time // Use the elapsed time since last frame was RENDERED (after frame skipping) const now = performance.now(); const elapsed = now - state.lastFrameTime; const currentFPS = elapsed > 0 ? Math.round(1000 / elapsed) : 60; // Maintain FPS history (last 60 samples for more stable detection) state.fpsHistory.push(currentFPS); if (state.fpsHistory.length > 60) { state.fpsHistory.shift(); } // Only check average FPS after collecting enough samples (1 second at 60fps) if (state.fpsHistory.length >= 60) { const avgFPS = state.fpsHistory.reduce((a, b) => a + b, 0) / state.fpsHistory.length; const minFPS = Math.min(...state.fpsHistory); const maxFPS = Math.max(...state.fpsHistory); // Check if device is struggling: // 1. Average FPS is below threshold AND // 2. FPS is consistently low (min and max are both low) AND // 3. We're not already in struggling mode const strugglingThreshold = CONFIG.strugglingDeviceThreshold || 25; const recoveryThreshold = CONFIG.strugglingDeviceRecoveryThreshold || 40; if (avgFPS < strugglingThreshold && maxFPS < strugglingThreshold + 10 && !state.strugglingDevice) { // Device is struggling - enable performance mode console.log('[Spaxel] Struggling device detected (avg FPS: ' + avgFPS.toFixed(1) + ', min: ' + minFPS + '), capping at 30 FPS'); state.strugglingDevice = true; state.targetFPS = CONFIG.mobileFrameRateCap || 30; state.minFrameTime = 1000 / state.targetFPS; // Show a toast notification to inform the user showToast('Performance mode enabled (30 FPS)', 'info'); // Disable expensive visual effects on struggling devices disableExpensiveEffects(); } // Recover if FPS improves consistently else if (avgFPS > recoveryThreshold && minFPS > recoveryThreshold - 5 && state.strugglingDevice) { console.log('[Spaxel] Performance improved (avg FPS: ' + avgFPS.toFixed(1) + '), restoring 60 FPS'); state.strugglingDevice = false; state.targetFPS = state.manualFrameRateCap || 60; state.minFrameTime = 1000 / state.targetFPS; // Re-enable effects that were disabled enableExpensiveEffects(); } } } /** * Disable expensive visual effects to improve performance on struggling devices. */ function disableExpensiveEffects() { // Reduce trail length if (window.Viz3D && window.Viz3D.setTrailLength) { window.Viz3D.setTrailLength(20); // Reduce from default } // Disable Fresnel zone debug overlay if active if (state.fresnelDebugVisible) { toggleFresnelDebugOverlay(false); } // Disable crowd flow visualization if (window.Viz3D && window.Viz3D.setFlowLayerVisible) { window.Viz3D.setFlowLayerVisible(false); } console.log('[Spaxel] Disabled expensive effects for performance'); } /** * Re-enable visual effects when performance recovers. */ function enableExpensiveEffects() { // Restore trail length if (window.Viz3D && window.Viz3D.setTrailLength) { window.Viz3D.setTrailLength(60); // Restore default } console.log('[Spaxel] Re-enabled visual effects'); } // ============================================ // System Quality Gauge // ============================================ function updateQualityGauge(score, linkCount, worstLinkID, worstScore) { const valueEl = document.getElementById('quality-value'); const fillEl = document.getElementById('quality-gauge-fill'); const linkCountEl = document.getElementById('quality-link-count'); const worstLinkEl = document.getElementById('quality-worst-link'); const worstScoreEl = document.getElementById('quality-worst-score'); if (!valueEl || !fillEl) return; // Update percentage display const pct = Math.round(score * 100); valueEl.textContent = pct + '%'; // Update circular gauge (stroke-dasharray: circumference = 2 * PI * r = ~81.7 for r=13) const circumference = 2 * Math.PI * 13; const dashLength = (score * circumference).toFixed(1); fillEl.setAttribute('stroke-dasharray', dashLength + ' ' + circumference); // Update color based on score let color; if (score >= 0.7) { color = '#66bb6a'; // green } else if (score >= 0.4) { color = '#eab308'; // yellow } else { color = '#ef4444'; // red } fillEl.setAttribute('stroke', color); // Update tooltip if (linkCountEl) linkCountEl.textContent = linkCount; if (worstLinkEl) worstLinkEl.textContent = worstLinkID ? abbreviateLinkID(worstLinkID) : '--'; if (worstScoreEl) worstScoreEl.textContent = worstScore !== null ? Math.round(worstScore * 100) + '%' : '--'; } function startHealthPolling() { if (state.healthPollTimer) { clearInterval(state.healthPollTimer); } fetchLinkHealth(); state.healthPollTimer = setInterval(fetchLinkHealth, CONFIG.healthPollIntervalMs); } function fetchLinkHealth() { fetch('/api/links') .then(function(res) { return res.json(); }) .then(function(links) { handleLinkHealthUpdate(links); }) .catch(function(err) { console.error('[Spaxel] Failed to fetch link health:', err); }); } function handleLinkHealthUpdate(links) { if (!links || links.length === 0) { state.systemHealth = 0; state.worstLinkID = null; state.worstLinkScore = 1.0; updateQualityGauge(0, 0, null, null); return; } // Calculate system health (weighted average of all links) var totalScore = 0; var worstScore = 1.0; var worstID = null; // Prepare link data for proactive quality monitoring var linksForProactive = links.map(function(link) { return { link_id: link.link_id, composite_score: link.health_score, quality: link.health_score, health_score: link.health_score, health_details: link.health_details }; }); links.forEach(function(link) { var score = link.health_score !== undefined ? link.health_score : 0.5; totalScore += score; if (score < worstScore) { worstScore = score; worstID = link.link_id; } }); state.systemHealth = totalScore / links.length; state.worstLinkID = worstID; state.worstLinkScore = worstScore; updateQualityGauge(state.systemHealth, links.length, worstID, worstScore); // Dispatch spaxel:update event for proactive quality monitoring window.dispatchEvent(new CustomEvent('spaxel:update', { detail: { links: linksForProactive } })); // Also update 3D visualization if (window.Viz3D && window.Viz3D.updateLinkHealth) { Viz3D.updateLinkHealth(links); } // Also update LinkHealth panel if (window.LinkHealth && window.LinkHealth.updateLinkHealth) { LinkHealth.updateLinkHealth(links); } } // ============================================ // Diurnal Learning Status // ============================================ function startDiurnalPolling() { if (state.diurnalPollTimer) { clearInterval(state.diurnalPollTimer); } fetchDiurnalStatus(); state.diurnalPollTimer = setInterval(fetchDiurnalStatus, CONFIG.diurnalPollIntervalMs); } function fetchDiurnalStatus() { fetch('/api/diurnal/status') .then(function(res) { return res.json(); }) .then(function(statuses) { handleDiurnalStatusUpdate(statuses); }) .catch(function(err) { console.error('[Spaxel] Failed to fetch diurnal status:', err); }); } function handleDiurnalStatusUpdate(statuses) { if (!statuses || statuses.length === 0) { updateDiurnalBanner(null); return; } // Find the link with the longest remaining learning time var worstStatus = null; statuses.forEach(function(status) { state.diurnalStatus.set(status.link_id, status); if (!worstStatus || status.days_remaining > worstStatus.days_remaining) { if (status.is_learning) { worstStatus = status; } } }); updateDiurnalBanner(worstStatus); } function updateDiurnalBanner(status) { var banner = document.getElementById('diurnal-banner'); var message = document.getElementById('diurnal-message'); var progress = document.getElementById('diurnal-progress'); var daysLeft = document.getElementById('diurnal-days-left'); if (!banner) return; if (!status || !status.is_learning) { banner.classList.remove('visible'); return; } banner.classList.add('visible'); if (message) { message.textContent = 'Learning your home\'s daily patterns...'; } if (progress) { var pct = Math.min(100, Math.max(0, status.progress || 0)); progress.style.width = pct + '%'; } if (daysLeft) { var days = Math.ceil(status.days_remaining || 0); if (days > 0) { daysLeft.textContent = days + (days === 1 ? ' day left' : ' days left'); } else { daysLeft.textContent = 'Almost ready...'; } } } function showToast(message, type) { var container = document.getElementById('toast-container'); if (!container) return; var toast = document.createElement('div'); toast.className = 'toast toast-' + (type || 'info'); toast.textContent = message; container.appendChild(toast); setTimeout(function() { toast.classList.add('fade-out'); setTimeout(function() { if (toast.parentNode) { toast.parentNode.removeChild(toast); } }, 300); }, 5000); } function updateFPS() { state.frameCount++; const now = performance.now(); const elapsed = now - state.lastFpsTime; if (elapsed >= 1000) { const fps = Math.round(state.frameCount * 1000 / elapsed); document.getElementById('fps-counter').textContent = fps; state.frameCount = 0; state.lastFpsTime = now; } } // ============================================ // WebSocket Connection (via SpaxelWebSocket) // ============================================ function connectWebSocket() { // Initialize the WebSocket manager with callbacks SpaxelWebSocket.init({ onOpen: function(ws) { console.log('[Spaxel] WebSocket connected'); state.wsConnected = true; state.awaitingSnapshot = true; }, onMessage: function(data) { handleMessage(data); }, onClose: function(event) { console.log('[Spaxel] WebSocket closed:', event.code, event.reason); state.wsConnected = false; // Start blob extrapolation using captured blob states SpaxelWebSocket.startExtrapolation(); }, onError: function(error) { console.error('[Spaxel] WebSocket error:', error); } }); SpaxelWebSocket.connect(); } // ============================================ // Message Handling // ============================================ // Event handlers for new message types function handleEventMessage(msg) { if (!msg.event) return; const event = msg.event; // Dedup: skip if we already processed this event (from immediate broadcast or prior delta) const eid = String(event.id); if (state.recentEventIDs.has(eid)) return; state.recentEventIDs.add(eid); // Prune old IDs every 30 seconds to prevent unbounded growth const now = Date.now(); if (now > state.recentEventIDsPruneAt) { state.recentEventIDs.clear(); state.recentEventIDsPruneAt = now + 30000; } console.log('[Spaxel] Event:', event.kind, 'in', event.zone, 'by', event.person_name || 'blob #' + event.blob_id); // Log to timeline const timeStr = new Date(event.ts).toLocaleTimeString(); let description = ''; if (event.kind === 'zone_entry') { description = (event.person_name || 'Someone') + ' entered ' + event.zone; } else if (event.kind === 'zone_exit') { description = (event.person_name || 'Someone') + ' left ' + event.zone; } else if (event.kind === 'portal_crossing') { description = (event.person_name || 'Someone') + ' crossed portal in ' + event.zone; } else if (event.kind === 'presence_transition') { description = (event.person_name || 'Someone') + ' presence detected in ' + event.zone; } else { description = event.kind + ' in ' + event.zone; } logTimelineEvent(event.kind, null, description + ' (' + timeStr + ')'); // Show toast for security-relevant events if (event.kind === 'zone_entry' || event.kind === 'portal_crossing') { showToast(description, 'info'); } } function handleAlertMessage(msg) { if (!msg.alert) return; const alert = msg.alert; console.log('[Spaxel] Alert:', alert.severity, alert.description); // Show toast notification const toastType = alert.severity === 'critical' ? 'error' : 'warning'; showToast(alert.description, toastType); // Log to timeline const timeStr = new Date(alert.ts).toLocaleTimeString(); logTimelineEvent('alert', null, '[' + alert.severity.toUpperCase() + '] ' + alert.description + ' (' + timeStr + ')'); // Could trigger UI alert state here (e.g., show alert banner) if (window.showAlertBanner) { window.showAlertBanner(alert); } } function handleBLEScanMessage(msg) { if (!msg.devices || !Array.isArray(msg.devices)) return; console.log('[Spaxel] BLE scan: ' + msg.devices.length + ' devices'); // Update BLE device list state if (!state.bleDevices) { state.bleDevices = new Map(); } // Clear previous entries and add current devices state.bleDevices.clear(); msg.devices.forEach(function (device) { state.bleDevices.set(device.mac || device.addr, { mac: device.mac || device.addr, name: device.name || device.device_name || 'Unknown', rssi: device.rssi || device.rssi_dbm || 0, last_seen: device.last_seen || Date.now(), label: device.label || '', blob_id: device.blob_id || null }); }); // Update UI if BLE panel exists if (window.BLEPanel && window.BLEPanel.updateDevices) { window.BLEPanel.updateDevices(msg.devices); } } function handleTriggerStateMessage(msg) { if (!msg.trigger) return; const trigger = msg.trigger; console.log('[Spaxel] Trigger state:', trigger.name, 'enabled=' + trigger.enabled); // Update trigger state in UI if automation panel exists if (window.Automations && window.Automations.updateTriggerState) { window.Automations.updateTriggerState(trigger); } } function handleSystemHealthMessage(msg) { if (!msg.health) return; const health = msg.health; // Update system health display in UI const healthEl = document.getElementById('system-uptime'); if (healthEl) { const uptimeSec = health.uptime_s || 0; const days = Math.floor(uptimeSec / 86400); const hours = Math.floor((uptimeSec % 86400) / 3600); const mins = Math.floor((uptimeSec % 3600) / 60); if (days > 0) { healthEl.textContent = days + 'd ' + hours + 'h ' + mins + 'm'; } else { healthEl.textContent = hours + 'h ' + mins + 'm'; } } const memEl = document.getElementById('system-memory'); if (memEl) { memEl.textContent = (health.mem_mb || 0).toFixed(1) + ' MB'; } const goroutinesEl = document.getElementById('system-goroutines'); if (goroutinesEl) { goroutinesEl.textContent = health.go_routines || 0; } } function handleMessage(data) { if (typeof data === 'string') { // JSON message try { const msg = JSON.parse(data); handleJSONMessage(msg); } catch (e) { console.error('[Spaxel] Failed to parse JSON:', e); } } else if (data instanceof ArrayBuffer) { // Binary CSI frame handleBinaryFrame(data); } } function handleJSONMessage(msg) { // Snapshot: first message on every connect/reconnect. Contains full state. if (msg.type === 'snapshot') { handleSnapshot(msg); return; } // Incremental update: 10 Hz delta with no type field. // Only fields that changed since last tick are present. if (!msg.type) { handleIncrementalUpdate(msg); return; } switch (msg.type) { case 'state': // Initial state dump if (msg.nodes) { msg.nodes.forEach(node => { state.nodes.set(node.mac, { mac: node.mac, firmware: node.firmware_version, chip: node.chip, lastSeen: Date.now() }); }); } if (msg.links) { msg.links.forEach(link => { const existing = state.links.get(link.id) || {}; state.links.set(link.id, { nodeMAC: link.node_mac, peerMAC: link.peer_mac, lastFrame: Date.now(), lastCSI: existing.lastCSI || null, motionDetected: existing.motionDetected || false, deltaRMS: existing.deltaRMS || 0, ampHistory: existing.ampHistory || [], lastAmpSample: existing.lastAmpSample || 0 }); }); } if (msg.motion_states) { msg.motion_states.forEach(ms => applyMotionState(ms)); } updateNodeList(); updateLinkList(); Viz3D.applyLinks(msg.links || []); break; case 'node_connected': state.nodes.set(msg.mac, { mac: msg.mac, firmware: msg.firmware_version, chip: msg.chip, lastSeen: Date.now() }); updateNodeList(); if (window.SpaxelTroubleshoot) { window.SpaxelTroubleshoot.handleEvent('node_connected', msg); } // Show first-time tooltips on first node connection if (window.SpaxelTooltips && state.nodes.size === 1) { setTimeout(function () { window.SpaxelTooltips.showSequence(); }, 2000); } break; case 'node_disconnected': state.nodes.delete(msg.mac); updateNodeList(); if (window.SpaxelTroubleshoot) { window.SpaxelTroubleshoot.handleEvent('node_disconnected', msg); } break; case 'link_active': if (!state.links.has(msg.id)) { state.links.set(msg.id, { nodeMAC: msg.node_mac, peerMAC: msg.peer_mac, lastFrame: Date.now(), lastCSI: null, motionDetected: false, deltaRMS: 0, ampHistory: [], lastAmpSample: 0 }); updateLinkList(); } Viz3D.handleLinkActive(msg); break; case 'link_inactive': state.links.delete(msg.id); updateLinkList(); Viz3D.handleLinkInactive(msg); break; case 'motion_state': // Targeted broadcast on state change if (msg.links) { let changed = false; msg.links.forEach(ms => { if (applyMotionState(ms)) changed = true; }); if (changed) updateLinkList(); } break; case 'presence_update': handlePresenceUpdate(msg); break; case 'registry_state': Viz3D.handleRegistryState(msg); if (window.Placement) Placement.handleRegistryState(msg); // Merge virtual nodes into local state and refresh node list if (msg.nodes) { var registryMACs = new Set(msg.nodes.map(function (n) { return n.mac; })); msg.nodes.forEach(function (node) { if (!state.nodes.has(node.mac)) { state.nodes.set(node.mac, { mac: node.mac, firmware: '', chip: '', lastSeen: 0, virtual: !!node.virtual }); } else { var existing = state.nodes.get(node.mac); if (node.virtual) existing.virtual = true; } }); // Remove virtual nodes no longer in registry state.nodes.forEach(function (node, mac) { if (!registryMACs.has(mac) && node.virtual) { state.nodes.delete(mac); } }); updateNodeList(); } break; case 'loc_update': Viz3D.handleLocUpdate(msg); break; case 'link_health': // Health score update from server if (msg.links) { handleLinkHealthUpdate(msg.links); } break; case 'diurnal_ready': // Diurnal patterns learned notification showToast(msg.message || 'Daily patterns learned! Detection accuracy improved.', 'success'); // Refresh diurnal status fetchDiurnalStatus(); break; case 'event': // Event: presence transition, zone entry/exit, portal crossing handleEventMessage(msg); break; case 'alert': // Alert: anomaly detection, security mode trigger handleAlertMessage(msg); break; case 'ble_scan': // BLE device list update (5s interval) handleBLEScanMessage(msg); break; case 'trigger_state': // Automation trigger state change handleTriggerStateMessage(msg); break; case 'system_health': // System health stats (60s interval) handleSystemHealthMessage(msg); break; case 'morning_summary': // Sleep morning summary (shown once after wake time) if (window.SpaxelSleep && window.SpaxelSleep.handleMorningSummary) { window.SpaxelSleep.handleMorningSummary(msg); } break; case 'morning_briefing': // Full morning briefing with sleep, overnight events, system health, predictions if (window.SpaxelBriefing && window.SpaxelBriefing.showBriefing) { window.SpaxelBriefing.showBriefing(msg.briefing); } break; case 'sleep_status': // Live sleep session status updates if (window.SpaxelSleep && window.SpaxelSleep.handleSleepStatus) { window.SpaxelSleep.handleSleepStatus(msg); } break; case 'quality_drop': // Guided troubleshooting: zone quality degraded if (window.SpaxelTroubleshoot) { window.SpaxelTroubleshoot.handleEvent('quality_drop', msg); } break; case 'repeated_edit': // Guided troubleshooting: settings adjusted repeatedly if (window.SpaxelTroubleshoot) { window.SpaxelTroubleshoot.handleEvent('repeated_edit', msg); } break; case 'calibration_complete': // Guided troubleshooting: baseline calibration complete if (window.SpaxelTroubleshoot) { window.SpaxelTroubleshoot.handleEvent('calibration_complete', msg); } break; case 'node_offline': // Guided troubleshooting: node offline for >2 hours if (window.SpaxelTroubleshoot) { window.SpaxelTroubleshoot.handleEvent('node_disconnected', msg); } break; case 'replay_update': // Replay blob updates during time-travel debugging if (msg.blobs && Viz3D.updateReplayBlobs) { Viz3D.updateReplayBlobs(msg.blobs, msg.timestamp_ms); } break; case 'zone_change': // Zone created, updated, or deleted if (window.Viz3D && Viz3D.handleZoneChange) { Viz3D.handleZoneChange(msg); } break; case 'portal_change': // Portal created, updated, or deleted if (window.Viz3D && Viz3D.handlePortalChange) { Viz3D.handlePortalChange(msg); } break; case 'zone_occupancy': // Zone occupancy counts update if (window.Viz3D && Viz3D.handleZoneOccupancy) { Viz3D.handleZoneOccupancy(msg); } break; case 'zone_transition': // Portal crossing event if (window.Viz3D && Viz3D.handleZoneTransition) { Viz3D.handleZoneTransition(msg); } break; default: // Log unhandled types for future debugging console.log('[Spaxel] Unknown message type:', msg.type, msg); } } // ─── Snapshot + Incremental Update Protocol ───────────────────────────── function handleSnapshot(msg) { state.awaitingSnapshot = false; // On reconnect: clear trails, restore scene, log duration if (SpaxelWebSocket.isConnected()) { SpaxelWebSocket.onReconnected(); } console.log('[Spaxel] Received snapshot, rebuilding state'); // Store snapshot for blob extrapolation on future disconnects SpaxelWebSocket.setLastSnapshot(msg); // Nodes if (msg.nodes) { state.nodes.clear(); msg.nodes.forEach(function (node) { state.nodes.set(node.mac, { mac: node.mac, firmware: node.firmware_version, chip: node.chip, lastSeen: Date.now() }); }); } // Links if (msg.links) { state.links.clear(); msg.links.forEach(function (link) { state.links.set(link.id, { nodeMAC: link.node_mac, peerMAC: link.peer_mac, lastFrame: Date.now(), lastCSI: null, motionDetected: false, deltaRMS: 0, ampHistory: [], lastAmpSample: 0 }); }); } // Motion states if (msg.motion_states) { msg.motion_states.forEach(function (ms) { applyMotionState(ms); }); } // Blobs (localisation) if (msg.blobs) { Viz3D.handleLocUpdate({ type: 'loc_update', blobs: msg.blobs }); } // BLE devices if (msg.ble_devices) { handleBLEScanMessage({ type: 'ble_scan', devices: msg.ble_devices }); } // Triggers if (msg.triggers) { msg.triggers.forEach(function (trigger) { if (window.Automations && window.Automations.updateTriggerState) { window.Automations.updateTriggerState(trigger); } }); } // Zones if (msg.zones) { if (window.Viz3D && window.Viz3D.handleZoneUpdate) { Viz3D.handleZoneUpdate(msg.zones); } } // Portals if (msg.portals) { if (window.Viz3D && window.Viz3D.handlePortalUpdate) { Viz3D.handlePortalUpdate(msg.portals); } } updateNodeList(); updateLinkList(); Viz3D.applyLinks(msg.links || []); } function handleIncrementalUpdate(msg) { // Drop incremental updates until the snapshot has been received. if (state.awaitingSnapshot) return; // Blobs (always present when localisation is running) if (msg.blobs) { Viz3D.handleLocUpdate({ type: 'loc_update', blobs: msg.blobs }); } // Nodes (only present when node list changed) if (msg.nodes !== undefined) { if (msg.nodes.length > 0) { msg.nodes.forEach(function (node) { state.nodes.set(node.mac, { mac: node.mac, firmware: node.firmware_version, chip: node.chip, lastSeen: Date.now() }); }); } updateNodeList(); } // Links (only present when link list changed) if (msg.links !== undefined) { if (msg.links.length > 0) { msg.links.forEach(function (link) { state.links.set(link.id, { nodeMAC: link.node_mac, peerMAC: link.peer_mac, lastFrame: Date.now(), lastCSI: null, motionDetected: false, deltaRMS: 0, ampHistory: [], lastAmpSample: 0 }); }); } updateLinkList(); Viz3D.applyLinks(msg.links); } // Motion states (only present when motion state changed) if (msg.motion_states) { var changed = false; msg.motion_states.forEach(function (ms) { if (applyMotionState(ms)) changed = true; }); if (changed) updateLinkList(); } // BLE devices if (msg.ble_devices) { handleBLEScanMessage({ type: 'ble_scan', devices: msg.ble_devices }); } // Triggers if (msg.triggers) { msg.triggers.forEach(function (trigger) { if (window.Automations && window.Automations.updateTriggerState) { window.Automations.updateTriggerState(trigger); } }); } // Zones if (msg.zones) { if (window.Viz3D && window.Viz3D.handleZoneUpdate) { Viz3D.handleZoneUpdate(msg.zones); } } // Portals if (msg.portals) { if (window.Viz3D && window.Viz3D.handlePortalUpdate) { Viz3D.handlePortalUpdate(msg.portals); } } // Events buffered since last tick (presence transitions, zone entries/exits, portal crossings) if (msg.events && Array.isArray(msg.events)) { msg.events.forEach(function (evt) { handleEventMessage({ type: 'event', event: evt }); }); } } // applyMotionState updates a link's motion fields; returns true if it changed. function applyMotionState(ms) { const link = state.links.get(ms.link_id); if (!link) return false; const prev = link.motionDetected; link.motionDetected = ms.motion_detected; link.deltaRMS = ms.delta_rms || 0; // Phase 6: Breathing/dwell state link.breathingState = ms.breathing_state || 'CLEAR'; link.breathingBPM = ms.breathing_bpm || 0; return prev !== ms.motion_detected; } function handleBinaryFrame(buffer) { const frame = parseCSIFrame(buffer); if (!frame) return; const linkID = frame.linkID; // Update link state let link = state.links.get(linkID); if (!link) { link = { nodeMAC: frame.nodeMAC, peerMAC: frame.peerMAC, lastFrame: Date.now(), lastCSI: null, motionDetected: false, deltaRMS: 0, ampHistory: [], lastAmpSample: 0 }; state.links.set(linkID, link); updateLinkList(); } else { link.lastFrame = Date.now(); // Ensure time-series fields exist on links pre-created from JSON events if (!link.ampHistory) { link.ampHistory = []; link.lastAmpSample = 0; } } // Store CSI for chart rendering link.lastCSI = frame; // Push amplitude sample to time series (rate-limited) const now = performance.now(); if (now - link.lastAmpSample >= CONFIG.tsMinIntervalMs) { link.lastAmpSample = now; let sum = 0; for (let i = 0; i < frame.subcarriers.length; i++) { sum += frame.subcarriers[i].amplitude; } const meanAmp = frame.subcarriers.length > 0 ? sum / frame.subcarriers.length : 0; link.ampHistory.push({ t: now, amp: meanAmp, motion: link.motionDetected }); if (link.ampHistory.length > CONFIG.tsMaxPoints) { link.ampHistory.shift(); } } // Update charts if this is the selected link if (state.selectedLinkID === linkID) { if (now - state.lastChartUpdate >= CONFIG.chartUpdateMs) { drawAmplitudeChart(frame); drawTimeSeries(link.ampHistory); state.lastChartUpdate = now; } } } // ============================================ // CSI Frame Parsing (matches Go binary format) // ============================================ function parseCSIFrame(buffer) { const view = new DataView(buffer); const bytes = new Uint8Array(buffer); if (bytes.length < 24) { return null; // Header too short } const nodeMAC = formatMAC(bytes, 0); const peerMAC = formatMAC(bytes, 6); const timestampUS = view.getBigUint64(12, true); // little-endian const rssi = view.getInt8(20); const noiseFloor = view.getInt8(21); const channel = bytes[22]; const nSub = bytes[23]; if (channel === 0 || channel > 14) { return null; // Invalid channel } const expectedLen = 24 + nSub * 2; if (bytes.length !== expectedLen) { return null; // Payload length mismatch } // Extract I/Q pairs and compute amplitude const subcarriers = []; for (let i = 0; i < nSub; i++) { const offset = 24 + i * 2; const iVal = bytes[offset]; const qVal = bytes[offset + 1]; // Convert from unsigned to signed (JavaScript quirk) const I = iVal > 127 ? iVal - 256 : iVal; const Q = qVal > 127 ? qVal - 256 : qVal; const amplitude = Math.sqrt(I * I + Q * Q); subcarriers.push({ I, Q, amplitude }); } return { nodeMAC, peerMAC, linkID: `${nodeMAC}:${peerMAC}`, timestampUS: Number(timestampUS), rssi, noiseFloor, channel, nSub, subcarriers }; } function formatMAC(bytes, offset) { const parts = []; for (let i = 0; i < 6; i++) { parts.push(bytes[offset + i].toString(16).padStart(2, '0').toUpperCase()); } return parts.join(':'); } // ============================================ // UI Updates // ============================================ function updateNodeList() { const container = document.getElementById('node-list'); document.getElementById('node-count').textContent = state.nodes.size; if (state.nodes.size === 0) { container.innerHTML = '
No nodes connected
'; return; } let html = ''; state.nodes.forEach((node, mac) => { const isVirtual = !!node.virtual; const isOnline = isVirtual || Date.now() - node.lastSeen < 30000; const statusClass = isVirtual ? 'virtual' : (isOnline ? 'online' : 'offline'); const statusLabel = isVirtual ? 'Virtual' : (isOnline ? 'Online' : 'Offline'); // Check for OTA rollback state let rollbackBadge = ''; let otaBadge = ''; if (window.SpaxelOTA) { const otaProgress = SpaxelOTA.getProgress(); if (otaProgress && otaProgress[mac]) { const p = otaProgress[mac]; if (p.state === 'rollback') { rollbackBadge = 'ROLLBACK'; } else if (p.state === 'downloading' || p.state === 'rebooting') { otaBadge = 'OTA ' + (p.progress_pct || 0) + '%'; } else if (p.state === 'verified') { otaBadge = 'UPDATED'; } } } // Firmware version display (shortened) const fwDisplay = node.firmware ? '' + escapeHtml(node.firmware) + '' : ''; // Identify button (only for online nodes) const identifyBtn = isOnline ? `` : ''; html += `
${mac} ${fwDisplay} ${rollbackBadge} ${otaBadge} ${statusLabel} ${identifyBtn}
`; }); container.innerHTML = html; // Click-to-select for placement container.querySelectorAll('.node-item').forEach(function (el) { el.addEventListener('click', function () { if (window.Placement) Placement.selectNode(el.dataset.mac); }); }); } function escapeHtml(s) { if (!s) return ''; return String(s).replace(/&/g, '&').replace(//g, '>'); } // Identify node by blinking its LED function identifyNode(mac, durationMs) { const payload = durationMs ? JSON.stringify({ duration_ms: durationMs }) : JSON.stringify({}); fetch(`/api/nodes/${mac}/identify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload }) .then(res => { if (!res.ok) { throw new Error('Identify failed: ' + res.status); } return res.json(); }) .then(data => { showToast(`Identify command sent to ${mac}`, 'success'); }) .catch(err => { console.error('[Identify] Error:', err); showToast(`Failed to identify ${mac}: ${err.message}`, 'error'); }); } function updatePresenceIndicator() { let anyMotion = false; state.links.forEach(link => { if (link.motionDetected) anyMotion = true; }); const el = document.getElementById('presence-indicator'); if (anyMotion) { el.className = 'motion'; el.textContent = 'MOTION'; } else { el.className = 'clear'; el.textContent = 'CLEAR'; } } // ============================================ // Presence Panel // ============================================ function handlePresenceUpdate(msg) { if (!msg.links) return; const now = performance.now(); let anyMotion = false; let anyStationary = false; for (const [linkID, info] of Object.entries(msg.links)) { // Update link state if link exists const link = state.links.get(linkID); const prevBreathingState = link ? link.breathingState : 'CLEAR'; if (link) { link.motionDetected = info.is_motion || info.motion_detected || false; link.deltaRMS = info.delta_rms || 0; // Phase 6: Breathing/dwell state link.breathingState = info.breathing_state || 'CLEAR'; link.breathingBPM = info.breathing_bpm || 0; } if (info.is_motion || info.motion_detected) anyMotion = true; if (info.breathing_state === 'STATIONARY_DETECTED') anyStationary = true; // Log timeline event on transition to STATIONARY_DETECTED const newBreathingState = info.breathing_state || 'CLEAR'; if (prevBreathingState !== 'STATIONARY_DETECTED' && newBreathingState === 'STATIONARY_DETECTED') { const bpm = info.breathing_bpm || 0; const shortID = abbreviateLinkID(linkID); const timeStr = new Date().toLocaleTimeString(); logTimelineEvent('stationary_detected', linkID, 'Stationary person detected on ' + shortID + ' at ' + timeStr + ' - breathing at ' + bpm.toFixed(1) + ' BPM'); } // Append to deltaRMS history let history = state.drHistory.get(linkID); if (!history) { history = []; state.drHistory.set(linkID, history); } history.push({ t: now, rms: info.delta_rms || 0 }); // Trim to window const cutoff = now - CONFIG.drTsWindowMs; while (history.length > 0 && history[0].t < cutoff) { history.shift(); } if (history.length > CONFIG.drTsMaxPoints) { history.splice(0, history.length - CONFIG.drTsMaxPoints); } } updatePresencePanel(msg.links, anyMotion, anyStationary); updateLinkList(); drawDeltaRMSTimeSeries(); } // Timeline event logging function logTimelineEvent(eventType, linkID, message) { // Log to console for debugging console.log('[Timeline]', eventType, linkID, message); // Show toast notification for stationary detection if (eventType === 'stationary_detected') { showToast(message, 'info'); } // Could also append to a timeline panel in the UI if one exists const timelineEl = document.getElementById('timeline-events'); if (timelineEl) { const entry = document.createElement('div'); entry.className = 'timeline-entry timeline-' + eventType; entry.innerHTML = '' + new Date().toLocaleTimeString() + ' ' + message; timelineEl.insertBefore(entry, timelineEl.firstChild); // Keep only last 50 entries while (timelineEl.children.length > 50) { timelineEl.removeChild(timelineEl.lastChild); } } } function updatePresencePanel(links, anyMotion, anyStationary) { const container = document.getElementById('presence-list'); const statusEl = document.getElementById('presence-status'); // Update status indicator with priority: motion > stationary > clear if (anyMotion) { statusEl.className = 'motion'; statusEl.textContent = 'MOTION'; } else if (anyStationary) { statusEl.className = 'stationary'; statusEl.textContent = 'STATIONARY'; } else { statusEl.className = 'clear'; statusEl.textContent = 'CLEAR'; } const entries = Object.entries(links); if (entries.length === 0) { container.innerHTML = '
No links active
'; return; } let html = ''; for (const [linkID, info] of entries) { const isMotion = info.is_motion || info.motion_detected || false; const confidence = info.confidence || 0; const rms = info.delta_rms || 0; const breathingState = info.breathing_state || 'CLEAR'; const breathingBPM = info.breathing_bpm || 0; const isStationary = breathingState === 'STATIONARY_DETECTED'; const shortID = abbreviateLinkID(linkID); const selected = state.presenceSelectedLinkID === linkID ? 'selected' : ''; // Determine dot class based on state priority let dotClass = 'clear'; let dotTitle = 'No motion detected'; if (isStationary) { dotClass = 'stationary'; dotTitle = 'Stationary person detected - breathing at ' + breathingBPM.toFixed(1) + ' BPM'; } else if (isMotion && confidence > 0.7) { dotClass = 'high-confidence'; dotTitle = 'High confidence motion detected'; } else if (isMotion) { dotClass = 'motion'; dotTitle = 'Motion detected'; } else if (breathingState === 'POSSIBLY_PRESENT') { dotClass = 'possibly'; dotTitle = 'Possibly present (waiting for confirmation)'; } // Breathing info for tooltip/status let breathingInfo = ''; if (isStationary) { breathingInfo = '' + breathingBPM.toFixed(1) + ' BPM'; } html += `
${shortID} ${rms.toFixed(4)} ${breathingInfo}
`; } container.innerHTML = html; container.querySelectorAll('.presence-row').forEach(el => { el.addEventListener('click', () => { state.presenceSelectedLinkID = el.dataset.linkId; updatePresencePanel(links, anyMotion, anyStationary); }); }); } function abbreviateLinkID(linkID) { const parts = linkID.split(':'); if (parts.length >= 12) { // Full MAC:MAC format: AA:BB:CC:DD:EE:FF:AA:BB:CC:DD:EE:FF const nodeShort = parts.slice(3, 6).join(':'); const peerShort = parts.slice(9, 12).join(':'); return nodeShort + '\u2192' + peerShort; } // Fallback: last segment of each side const halves = linkID.split(':').reduce((acc, p, i, arr) => { if (i < 6) acc[0].push(p); else acc[1].push(p); return acc; }, [[], []]); return halves[0].slice(-2).join(':') + '\u2192' + halves[1].slice(-2).join(':'); } function updateLinkList() { const container = document.getElementById('link-list'); document.getElementById('link-count').textContent = state.links.size; if (state.links.size === 0) { container.innerHTML = '
No links active
'; return; } let html = ''; state.links.forEach((link, id) => { const selected = state.selectedLinkID === id ? 'selected' : ''; const shortID = id.split(':').map(p => p.split(':').slice(-1)[0]).join('→'); const motionClass = link.motionDetected ? 'motion' : 'clear'; const motionLabel = link.motionDetected ? 'MOTION' : 'CLEAR'; html += ` `; }); container.innerHTML = html; // Add click handlers container.querySelectorAll('.link-item').forEach(el => { el.addEventListener('click', () => selectLink(el.dataset.linkId)); }); updatePresenceIndicator(); } function selectLink(linkID) { state.selectedLinkID = linkID; updateLinkList(); // Update chart title document.querySelector('#chart-title .link-id').textContent = linkID || 'no link selected'; // Draw current data immediately if available const link = state.links.get(linkID); if (link) { if (link.lastCSI) drawAmplitudeChart(link.lastCSI); drawTimeSeries(link.ampHistory || []); } // Show diurnal baseline chart for this link if (typeof DiurnalChart !== 'undefined' && DiurnalChart.showForLink) { DiurnalChart.showForLink(linkID); } } // ============================================ // Amplitude Chart (Canvas 2D) + Time Series // ============================================ let chartCanvas, chartCtx; let tsCanvas, tsCtx; let drCanvas, drCtx; function initChart() { chartCanvas = document.getElementById('amplitude-chart'); chartCtx = chartCanvas.getContext('2d'); const rect = chartCanvas.getBoundingClientRect(); chartCanvas.width = rect.width * window.devicePixelRatio; chartCanvas.height = rect.height * window.devicePixelRatio; chartCtx.scale(window.devicePixelRatio, window.devicePixelRatio); drawEmptyChart(); tsCanvas = document.getElementById('timeseries-chart'); tsCtx = tsCanvas.getContext('2d'); const tsRect = tsCanvas.getBoundingClientRect(); tsCanvas.width = tsRect.width * window.devicePixelRatio; tsCanvas.height = tsRect.height * window.devicePixelRatio; tsCtx.scale(window.devicePixelRatio, window.devicePixelRatio); drawTimeSeries([]); drCanvas = document.getElementById('deltarms-chart'); drCtx = drCanvas.getContext('2d'); const drRect = drCanvas.getBoundingClientRect(); drCanvas.width = drRect.width * window.devicePixelRatio; drCanvas.height = drRect.height * window.devicePixelRatio; drCtx.scale(window.devicePixelRatio, window.devicePixelRatio); drawDeltaRMSTimeSeries(); } function drawEmptyChart() { const width = chartCanvas.width / window.devicePixelRatio; const height = chartCanvas.height / window.devicePixelRatio; chartCtx.fillStyle = '#1a1a2e'; chartCtx.fillRect(0, 0, width, height); chartCtx.fillStyle = '#444'; chartCtx.font = '12px sans-serif'; chartCtx.textAlign = 'center'; chartCtx.fillText('No data', width / 2, height / 2); } function drawAmplitudeChart(frame) { const width = chartCanvas.width / window.devicePixelRatio; const height = chartCanvas.height / window.devicePixelRatio; // Clear chartCtx.fillStyle = '#1a1a2e'; chartCtx.fillRect(0, 0, width, height); const subcarriers = frame.subcarriers; const nSub = subcarriers.length; if (nSub === 0) return; const barWidth = width / nSub; const padding = 1; // Find max amplitude for scaling let maxAmp = 0; subcarriers.forEach(s => { if (s.amplitude > maxAmp) maxAmp = s.amplitude; }); if (maxAmp === 0) maxAmp = 1; // Draw bars for (let i = 0; i < nSub; i++) { const amp = subcarriers[i].amplitude; const barHeight = (amp / maxAmp) * (height - 10); const x = i * barWidth + padding; const y = height - barHeight; // Gradient color based on amplitude const intensity = amp / maxAmp; const r = Math.floor(79 + intensity * (255 - 79)); const g = Math.floor(195 - intensity * 100); const b = Math.floor(247 - intensity * 150); chartCtx.fillStyle = `rgb(${r}, ${g}, ${b})`; chartCtx.fillRect(x, y, barWidth - padding * 2, barHeight); } // Draw channel/rssi info chartCtx.fillStyle = '#666'; chartCtx.font = '10px monospace'; chartCtx.textAlign = 'left'; chartCtx.fillText(`CH${frame.channel} RSSI:${frame.rssi}dBm`, 4, height - 4); } function drawTimeSeries(history) { if (!tsCanvas) return; const width = tsCanvas.width / window.devicePixelRatio; const height = tsCanvas.height / window.devicePixelRatio; tsCtx.fillStyle = '#1a1a2e'; tsCtx.fillRect(0, 0, width, height); if (history.length < 2) { tsCtx.fillStyle = '#444'; tsCtx.font = '10px sans-serif'; tsCtx.textAlign = 'center'; tsCtx.fillText('Waiting for data…', width / 2, height / 2 + 4); return; } // Find max amplitude for y-scale (with a minimum floor) let maxAmp = 1; for (let i = 0; i < history.length; i++) { if (history[i].amp > maxAmp) maxAmp = history[i].amp; } const padTop = 4; const padBottom = 14; // room for time label const plotH = height - padTop - padBottom; const xStep = width / (CONFIG.tsMaxPoints - 1); // Draw zero line tsCtx.strokeStyle = 'rgba(255,255,255,0.05)'; tsCtx.lineWidth = 1; tsCtx.beginPath(); tsCtx.moveTo(0, padTop + plotH); tsCtx.lineTo(width, padTop + plotH); tsCtx.stroke(); // Draw amplitude line, colored by motion state const startIdx = Math.max(0, CONFIG.tsMaxPoints - history.length); tsCtx.lineWidth = 1.5; for (let i = 0; i < history.length - 1; i++) { const x0 = (startIdx + i) * xStep; const x1 = (startIdx + i + 1) * xStep; const y0 = padTop + plotH - (history[i].amp / maxAmp) * plotH; const y1 = padTop + plotH - (history[i + 1].amp / maxAmp) * plotH; tsCtx.strokeStyle = history[i].motion ? 'rgba(239,83,80,0.8)' : 'rgba(102,187,106,0.7)'; tsCtx.beginPath(); tsCtx.moveTo(x0, y0); tsCtx.lineTo(x1, y1); tsCtx.stroke(); } // Time label: oldest → newest const oldest = history[0].t; const newest = history[history.length - 1].t; const spanS = ((newest - oldest) / 1000).toFixed(0); tsCtx.fillStyle = '#555'; tsCtx.font = '9px monospace'; tsCtx.textAlign = 'left'; tsCtx.fillText(`-${spanS}s`, 2, height - 2); tsCtx.textAlign = 'right'; tsCtx.fillText('now', width - 2, height - 2); } function drawDeltaRMSTimeSeries() { if (!drCanvas) return; const width = drCanvas.width / window.devicePixelRatio; const height = drCanvas.height / window.devicePixelRatio; drCtx.fillStyle = '#1a1a2e'; drCtx.fillRect(0, 0, width, height); const linkID = state.presenceSelectedLinkID; const history = linkID ? state.drHistory.get(linkID) : null; if (!history || history.length < 2) { drCtx.fillStyle = '#444'; drCtx.font = '10px sans-serif'; drCtx.textAlign = 'center'; drCtx.fillText('Select a link', width / 2, height / 2 + 4); return; } const padTop = 4; const padBottom = 14; const plotH = height - padTop - padBottom; // Y-axis range: 0 to 0.1 (typical deltaRMS range) const yMax = 0.1; // Draw threshold line at 0.02 const threshY = padTop + plotH - (CONFIG.drThreshold / yMax) * plotH; drCtx.strokeStyle = 'rgba(255, 167, 38, 0.5)'; drCtx.lineWidth = 1; drCtx.setLineDash([4, 3]); drCtx.beginPath(); drCtx.moveTo(0, threshY); drCtx.lineTo(width, threshY); drCtx.stroke(); drCtx.setLineDash([]); // Threshold label drCtx.fillStyle = 'rgba(255, 167, 38, 0.7)'; drCtx.font = '9px monospace'; drCtx.textAlign = 'left'; drCtx.fillText('0.02', 2, threshY - 2); // Time range const tEnd = history[history.length - 1].t; const tStart = tEnd - CONFIG.drTsWindowMs; // Draw deltaRMS line drCtx.lineWidth = 1.5; drCtx.strokeStyle = 'rgba(79, 195, 247, 0.8)'; drCtx.beginPath(); let started = false; for (let i = 0; i < history.length; i++) { const x = ((history[i].t - tStart) / CONFIG.drTsWindowMs) * width; const y = padTop + plotH - (Math.min(history[i].rms, yMax) / yMax) * plotH; if (!started) { drCtx.moveTo(x, y); started = true; } else { drCtx.lineTo(x, y); } } drCtx.stroke(); // Fill area under curve if (history.length >= 2) { const lastX = ((history[history.length - 1].t - tStart) / CONFIG.drTsWindowMs) * width; const firstX = ((history[0].t - tStart) / CONFIG.drTsWindowMs) * width; drCtx.lineTo(lastX, padTop + plotH); drCtx.lineTo(firstX, padTop + plotH); drCtx.closePath(); drCtx.fillStyle = 'rgba(79, 195, 247, 0.08)'; drCtx.fill(); } // Time labels drCtx.fillStyle = '#555'; drCtx.font = '9px monospace'; drCtx.textAlign = 'left'; drCtx.fillText('-10s', 2, height - 2); drCtx.textAlign = 'right'; drCtx.fillText('now', width - 2, height - 2); // Y-axis labels drCtx.textAlign = 'right'; drCtx.fillText('0.1', width - 2, padTop + 10); } // ============================================ // URL Parameter Handling // ============================================ function handleURLParameters() { // Parse URL parameters for camera fly-to and other features const params = new URLSearchParams(window.location.search); const highlightMAC = params.get('highlight'); if (highlightMAC && window.Viz3D && window.Viz3D.flyToNode) { console.log('[Spaxel] Highlight parameter found, flying to node:', highlightMAC); // Wait a bit for scene to fully initialize before flying setTimeout(function() { window.Viz3D.flyToNode(highlightMAC); // Clear the parameter from URL without reloading const url = new URL(window.location); url.searchParams.delete('highlight'); window.history.replaceState({}, '', url); }, 500); } } // ============================================ // Initialization // ============================================ function init() { console.log('[Spaxel] Dashboard initializing...'); initScene(); initChart(); connectWebSocket(); startHealthPolling(); startDiurnalPolling(); animate(); // Handle URL parameters after initialization handleURLParameters(); console.log('[Spaxel] Dashboard ready'); } // Start when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // ============================================ // Public API // ============================================ // Message handlers registered by other modules (e.g., FleetPanel) const messageHandlers = []; function registerMessageHandler(handler) { if (typeof handler === 'function') { messageHandlers.push(handler); } } window.SpaxelApp = { getLinks: function () { return state.links; }, getNodes: function () { return state.nodes; }, refreshNodeList: updateNodeList, refreshLinkList: updateLinkList, showToast: showToast, registerMessageHandler: registerMessageHandler, /** * Get the current target frame rate. * @returns {number} Target FPS (60 for desktop, 30-60 for mobile) */ getTargetFPS: function() { return state.targetFPS; }, /** * Get whether the device is currently in struggling mode. * @returns {boolean} True if frame rate is capped due to poor performance */ isStrugglingDevice: function() { return state.strugglingDevice; }, /** * Get the current FPS history for debugging. * @returns {Array} Array of recent FPS samples */ getFPSHistory: function() { return state.fpsHistory.slice(); }, /** * Set a manual frame rate cap (overrides auto-detection). * @param {number} fps - Target FPS (30 for struggling mobile, 60 for normal, null to reset) */ setFrameRateCap: function(fps) { if (fps === null || fps === undefined) { // Reset to auto-detection state.manualFrameRateCap = null; if (state.strugglingDevice) { // Keep struggling mode cap state.targetFPS = CONFIG.mobileFrameRateCap || 30; state.minFrameTime = 1000 / state.targetFPS; } else { // Restore to 60 FPS state.targetFPS = 60; state.minFrameTime = 1000 / 60; } console.log('[Spaxel] Frame rate cap reset to auto-detection (target: ' + state.targetFPS + ' FPS)'); } else if (typeof fps === 'number' && fps > 0 && fps <= 60) { // Set manual cap state.manualFrameRateCap = fps; state.targetFPS = fps; state.minFrameTime = 1000 / fps; console.log('[Spaxel] Frame rate manually capped at ' + fps + ' FPS'); } else { console.warn('[Spaxel] Invalid frame rate cap: ' + fps + ' (must be 1-60)'); } }, /** * Enable or disable auto-detection of struggling devices. * @param {boolean} enabled - Whether to enable auto-detection */ setAutoDetectStrugglingDevice: function(enabled) { CONFIG.autoDetectStrugglingDevice = enabled; console.log('[Spaxel] Auto-detect struggling device: ' + (enabled ? 'enabled' : 'disabled')); } }; // ============================================ // Crowd Flow Visualization Controls // Global wrappers for HTML onchange handlers -> Viz3D module // ============================================ window.identifyNode = identifyNode; window.toggleFlowLayer = function(visible) { Viz3D.setFlowLayerVisible(visible); }; window.toggleDwellLayer = function(visible) { Viz3D.setDwellLayerVisible(visible); }; window.toggleCorridorLayer = function(visible) { Viz3D.setCorridorLayerVisible(visible); }; window.setFlowTimeFilter = function(value) { Viz3D.setFlowTimeFilter(value); }; window.toggleFresnelZones = function() { var btn = document.getElementById('fresnel-toggle-btn'); var isActive = btn && btn.classList.contains('active'); if (isActive) { // Turn off Viz3D.toggleFresnelZones(false); if (btn) btn.classList.remove('active'); } else { // Turn on Viz3D.toggleFresnelZones(true); if (btn) btn.classList.add('active'); } }; // ============================================ // Fresnel Zone Debug Overlay // ============================================ /** * Toggle Fresnel zone debug overlay for all active links. * @param {boolean} visible - Whether to show Fresnel zones */ window.toggleFresnelDebugOverlay = function(visible) { state.fresnelDebugVisible = visible; if (visible) { rebuildFresnelDebugEllipsoids(); } else { clearFresnelDebugEllipsoids(); } }; /** * Rebuild Fresnel zone ellipsoids for all active links. * Called when the overlay is toggled on or when links change. */ function rebuildFresnelDebugEllipsoids() { if (!state.fresnelDebugVisible) return; if (!window.Fresnel) { console.warn('[Fresnel Debug] Fresnel module not loaded'); return; } // Clear existing ellipsoids clearFresnelDebugEllipsoids(); // Get node positions from Viz3D var nodeMeshes = Viz3D.getNodeMesh ? Viz3D.getNodeMesh() : new Map(); // Create ellipsoids for each active link state.links.forEach(function(link, linkID) { var parts = linkID.split(':'); if (parts.length < 2) return; var txMAC = link.nodeMAC || parts[0]; var rxMAC = link.peerMAC || parts[1]; var txMesh = nodeMeshes.get(txMAC); var rxMesh = nodeMeshes.get(rxMAC); if (!txMesh || !rxMesh) return; var tx = txMesh.position; var rx = rxMesh.position; // Get channel from link health data (default to 6 for 2.4 GHz) var healthData = state.worstLinkID === linkID ? { score: state.worstLinkScore } : null; var channel = 6; // Default 2.4 GHz channel // Determine color based on link health var healthScore = healthData ? healthData.score : 0.5; var color = getFresnelHealthColor(healthScore); // Create Fresnel ellipsoid var ellipsoid = window.Fresnel.addFresnelEllipsoid(tx, rx, channel, color); if (ellipsoid) { // Store link info in userData for interactions ellipsoid.wireframe.userData.linkID = linkID; ellipsoid.wireframe.userData.txMAC = txMAC; ellipsoid.wireframe.userData.rxMAC = rxMAC; ellipsoid.fill.userData.linkID = linkID; ellipsoid.fill.userData.txMAC = txMAC; ellipsoid.fill.userData.rxMAC = rxMAC; state.fresnelEllipsoids.set(linkID, ellipsoid); } }); console.log('[Fresnel Debug] Created ' + state.fresnelEllipsoids.size + ' Fresnel ellipsoids'); } /** * Clear all Fresnel debug ellipsoids from the scene. */ function clearFresnelDebugEllipsoids() { state.fresnelEllipsoids.forEach(function(ellipsoid) { if (window.Fresnel) { window.Fresnel.removeFresnelEllipsoid(ellipsoid); } }); state.fresnelEllipsoids.clear(); hideFresnelTooltip(); } /** * Get color for Fresnel zone based on link health score. * @param {number} score - Health score (0-1) * @returns {number} Color hex value */ function getFresnelHealthColor(score) { if (score >= 0.7) return 0x66bb6a; // green if (score >= 0.4) return 0xeab308; // yellow return 0xef4444; // red } /** * Handle mouse move events for Fresnel ellipsoid hover detection. */ function onFresnelMouseMove(event) { if (!state.fresnelDebugVisible) return; // Calculate mouse position in normalized device coordinates var rect = renderer.domElement.getBoundingClientRect(); state.fresnelMouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; state.fresnelMouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; // Raycast against all Fresnel ellipsoids state.fresnelRaycaster.setFromCamera(state.fresnelMouse, camera); var intersects = []; state.fresnelEllipsoids.forEach(function(ellipsoid) { var result = state.fresnelRaycaster.intersectObject(ellipsoid.fill, true); if (result.length > 0) { intersects.push(result[0]); } }); if (intersects.length > 0) { var intersect = intersects[0]; var linkID = intersect.object.userData.linkID; if (state.fresnelHoveredEllipsoid !== linkID) { // New hover state.fresnelHoveredEllipsoid = linkID; highlightFresnelLink(linkID, true); showFresnelTooltip(event, linkID, intersect.point); } else { // Update tooltip position updateFresnelTooltipPosition(event); } } else { if (state.fresnelHoveredEllipsoid !== null) { // Hover ended highlightFresnelLink(state.fresnelHoveredEllipsoid, false); state.fresnelHoveredEllipsoid = null; hideFresnelTooltip(); } } } /** * Handle click events on Fresnel ellipsoids. */ function onFresnelClick(event) { if (!state.fresnelDebugVisible || state.fresnelHoveredEllipsoid === null) return; var linkID = state.fresnelHoveredEllipsoid; // Select the corresponding link in the link panel selectLink(linkID); // Flash the link entry to highlight it var linkItem = document.querySelector('.link-item[data-link-id="' + linkID + '"]'); if (linkItem) { linkItem.classList.add('flash-highlight'); setTimeout(function() { linkItem.classList.remove('flash-highlight'); }, 1000); } } /** * Highlight or unhighlight a link when its Fresnel ellipsoid is hovered. * @param {string} linkID - Link ID * @param {boolean} highlight - Whether to highlight */ function highlightFresnelLink(linkID, highlight) { var linkItem = document.querySelector('.link-item[data-link-id="' + linkID + '"]'); if (linkItem) { if (highlight) { linkItem.classList.add('fresnel-hover'); } else { linkItem.classList.remove('fresnel-hover'); } } // Also highlight the link line in 3D if Viz3D supports it if (window.Viz3D && window.Viz3D.highlightLink) { window.Viz3D.highlightLink(linkID, highlight); } } /** * Show tooltip with Fresnel ellipsoid details. * @param {MouseEvent} event - Mouse event * @param {string} linkID - Link ID * @param {THREE.Vector3} point - 3D point of intersection */ function showFresnelTooltip(event, linkID, point) { var tooltip = document.getElementById('fresnel-tooltip'); if (!tooltip) { tooltip = document.createElement('div'); tooltip.id = 'fresnel-tooltip'; tooltip.className = 'fresnel-tooltip'; document.body.appendChild(tooltip); } var link = state.links.get(linkID); var ellipsoid = state.fresnelEllipsoids.get(linkID); if (!link || !ellipsoid) return; var data = ellipsoid.data; var healthScore = state.worstLinkID === linkID ? state.worstLinkScore : 0.5; var txLabel = state.nodes.get(data.txMAC) ? state.nodes.get(data.txMAC).mac : data.txMAC; var rxLabel = state.nodes.get(data.rxMAC) ? state.nodes.get(data.rxMAC).mac : data.rxMAC; tooltip.innerHTML = 'Link: ' + abbreviateLinkID(linkID) + '
' + 'TX: ' + txLabel + '
' + 'RX: ' + rxLabel + '
' + 'Fresnel radius at midpoint: ' + data.b.toFixed(3) + ' m
' + 'Link distance: ' + data.d.toFixed(2) + ' m
' + 'Wavelength: ' + data.lambda.toFixed(3) + ' m (ch ' + data.channel + ')
' + 'Link health: ' + Math.round(healthScore * 100) + '%'; tooltip.style.display = 'block'; updateFresnelTooltipPosition(event); } /** * Update tooltip position based on mouse event. * @param {MouseEvent} event - Mouse event */ function updateFresnelTooltipPosition(event) { var tooltip = document.getElementById('fresnel-tooltip'); if (!tooltip) return; tooltip.style.left = (event.clientX + 15) + 'px'; tooltip.style.top = (event.clientY + 15) + 'px'; } /** * Hide the Fresnel tooltip. */ function hideFresnelTooltip() { var tooltip = document.getElementById('fresnel-tooltip'); if (tooltip) { tooltip.style.display = 'none'; } } // Add Fresnel interaction event listeners after scene initialization var originalInitScene = initScene; initScene = function() { originalInitScene(); // Initialize Fresnel module with scene if (window.Fresnel && window.Fresnel.init) { window.Fresnel.init(scene); } // Initialize VolumeEditor with scene, camera, controls, renderer if (window.VolumeEditor && window.VolumeEditor.init) { window.VolumeEditor.init(scene, camera, controls, renderer); } // Add event listeners for Fresnel interaction renderer.domElement.addEventListener('mousemove', onFresnelMouseMove); renderer.domElement.addEventListener('click', onFresnelClick); // Show debug section if expert mode (always visible for now) var debugSection = document.getElementById('debug-section'); if (debugSection) { debugSection.style.display = 'block'; } }; // Update Fresnel ellipsoids when links change var originalUpdateLinkList = updateLinkList; updateLinkList = function() { originalUpdateLinkList(); if (state.fresnelDebugVisible) { rebuildFresnelDebugEllipsoids(); } }; // ============================================================ // State exposure for Command Palette (Component 34) // ============================================================ /** * Returns a snapshot of the current app state for use by the command palette * and other modules. Read-only view — callers must not mutate returned objects. */ window.spaxelGetState = function () { return { nodes: Array.from(state.nodes.values()), links: Array.from(state.links.values()), bleDevices: Array.from(state.bleDevices.values()) }; }; // Ctrl+K / Cmd+K → Command Palette (expert mode only) document.addEventListener('keydown', function (e) { if ((e.ctrlKey || e.metaKey) && e.key === 'k') { // CommandPaletteManager registers its own handler; let it run. // This listener only acts as a fallback if the manager is not loaded. if (!window.CommandPaletteManager) { e.preventDefault(); } } }); })();