spaxel/dashboard/js/app.js
jedarden fb5937af2f feat(dashboard): add Fresnel zone debug overlay with shared geometry module
Fresnel zone ellipsoids render for all active links when the debug layer
is toggled on. Uses shared fresnel.js helper for geometry computation,
with hover tooltips showing link details and click-to-select. viz3d.js
refactored to use the shared module instead of duplicating calculations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 01:43:51 -04:00

2652 lines
97 KiB
JavaScript

/**
* 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() || !CONFIG.autoDetectStrugglingDevice) return;
// 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,
unpaired: !!msg.unpaired,
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 'blob_explain':
// Explainability snapshot from the fusion engine
if (window.Explainability && msg.blob_id !== undefined && msg.snapshot) {
Explainability.handleExplainSnapshot(msg.blob_id, msg.snapshot);
}
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,
channel: frame.channel
};
state.links.set(linkID, link);
updateLinkList();
} else {
link.lastFrame = Date.now();
link.channel = frame.channel;
// 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 = '<div class="empty-state">No nodes connected</div>';
return;
}
let html = '';
state.nodes.forEach((node, mac) => {
const isVirtual = !!node.virtual;
const isOnline = isVirtual || Date.now() - node.lastSeen < 30000;
const isUnpaired = !!node.unpaired;
const statusClass = isUnpaired ? 'unpaired' : (isVirtual ? 'virtual' : (isOnline ? 'online' : 'offline'));
const statusLabel = isUnpaired ? 'Unpaired' : (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 = '<span class="node-rollback-badge">ROLLBACK</span>';
} else if (p.state === 'downloading' || p.state === 'rebooting') {
otaBadge = '<span class="node-ota-badge">OTA ' + (p.progress_pct || 0) + '%</span>';
} else if (p.state === 'verified') {
otaBadge = '<span class="node-verified-badge">UPDATED</span>';
}
}
}
// Firmware version display (shortened)
const fwDisplay = node.firmware ? '<span class="node-fw">' + escapeHtml(node.firmware) + '</span>' : '';
// Identify button (only for online nodes)
const identifyBtn = isOnline ? `<button class="node-identify-btn" onclick="identifyNode('${mac}'); event.stopPropagation();" title="Identify (blink LED)">⚡</button>` : '';
html += `
<div class="node-item" data-mac="${mac}">
<span class="node-mac">${mac}</span>
${fwDisplay}
${rollbackBadge}
${otaBadge}
<span class="node-status ${statusClass}">
${statusLabel}
</span>
${identifyBtn}
</div>
`;
});
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// 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 = '<span class="timeline-time">' + new Date().toLocaleTimeString() + '</span> ' + 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 = '<div class="empty-state">No links active</div>';
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 = '<span class="breathing-bpm">' + breathingBPM.toFixed(1) + ' BPM</span>';
}
html += `
<div class="presence-row ${selected}" data-link-id="${linkID}" title="${dotTitle}">
<span class="presence-dot ${dotClass}"></span>
<span class="presence-link-id">${shortID}</span>
<span class="presence-rms">${rms.toFixed(4)}</span>
${breathingInfo}
</div>
`;
}
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 = '<div class="empty-state">No links active</div>';
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 += `
<div class="link-item ${selected}" data-link-id="${id}">
<span>${shortID}</span>
<span class="presence-badge ${motionClass}">${motionLabel}</span>
</div>
`;
});
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...');
// Parse frame rate cap from URL parameter before renderer init
// ?maxFps=30 → cap at 30 FPS on mobile (default for struggling devices)
// ?maxFps=60 → no cap, let auto-detection decide
// ?maxFps=0 → disable frame rate capping and auto-detection entirely
const urlParams = new URLSearchParams(window.location.search);
const maxFpsParam = urlParams.get('maxFps');
if (maxFpsParam !== null) {
const fps = parseInt(maxFpsParam, 10);
if (!isNaN(fps) && fps >= 0 && fps <= 60) {
if (fps === 0) {
CONFIG.autoDetectStrugglingDevice = false;
CONFIG.mobileFrameRateCap = 60;
} else {
CONFIG.mobileFrameRateCap = fps;
}
console.log('[Spaxel] Frame rate cap set to ' + (fps === 0 ? 'disabled' : fps + ' FPS') + ' via URL parameter');
} else {
console.warn('[Spaxel] Invalid maxFps value: ' + maxFpsParam + ' (must be 0-60)');
}
}
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<number>} 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() {
if (window.Layers) {
Layers.toggleLayer(Layers.LAYERS.FRESNEL);
} else {
// Fallback without Layers module
var btn = document.getElementById('fresnel-toggle-btn');
var isActive = btn && btn.classList.contains('active');
toggleFresnelDebugOverlay(!isActive);
}
};
// Expose for viz3d.js to call when links rebuild
window.rebuildFresnelDebugEllipsoids = function() {
if (state.fresnelDebugVisible) {
rebuildFresnelDebugEllipsoids();
}
};
// ============================================
// 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;
// Sync toolbar button
var btn = document.getElementById('fresnel-toggle-btn');
if (btn) {
if (visible) btn.classList.add('active');
else btn.classList.remove('active');
}
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 = (window.Viz3D && Viz3D.getNodeMeshes) ? Viz3D.getNodeMeshes() : 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's last CSI frame (default to 6 for 2.4 GHz)
var channel = link.channel || 6;
// Get per-link health score from Viz3D
var healthScore = 0.5;
if (window.Viz3D && Viz3D.getLinkHealth) {
var healthData = Viz3D.getLinkHealth(linkID);
if (healthData) healthScore = healthData.score;
}
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.wireframe.userData.healthScore = healthScore;
ellipsoid.fill.userData.linkID = linkID;
ellipsoid.fill.userData.txMAC = txMAC;
ellipsoid.fill.userData.rxMAC = rxMAC;
ellipsoid.fill.userData.healthScore = healthScore;
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 (window.Viz3D) {
if (highlight) {
// Brighten link line on hover
var healthData = Viz3D.getLinkHealth ? Viz3D.getLinkHealth(linkID) : null;
var score = healthData ? healthData.score : 0.5;
var color = getFresnelHealthColor(score);
if (Viz3D.highlightLink) Viz3D.highlightLink(linkID, color);
} else {
// Rebuild link lines to restore normal appearance
if (Viz3D.applyLinks) {
var links = [];
state.links.forEach(function(link, id) {
links.push({ id: id, node_mac: link.nodeMAC, peer_mac: link.peerMAC });
});
Viz3D.applyLinks(links);
}
}
}
}
/**
* 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 = 0.5;
if (window.Viz3D && Viz3D.getLinkHealth) {
var healthData = Viz3D.getLinkHealth(linkID);
if (healthData) healthScore = healthData.score;
}
var txMAC = link.nodeMAC;
var rxMAC = link.peerMAC;
var txNode = state.nodes.get(txMAC);
var rxNode = state.nodes.get(rxMAC);
var txLabel = txNode ? (txNode.name || txNode.mac) : txMAC;
var rxLabel = rxNode ? (rxNode.name || rxNode.mac) : rxMAC;
tooltip.innerHTML =
'<strong>Link:</strong> ' + txLabel + ' to ' + rxLabel + '<br>' +
'<strong>Fresnel radius at midpoint:</strong> ' + data.b.toFixed(2) + ' m<br>' +
'<strong>Link distance:</strong> ' + data.d.toFixed(2) + ' m<br>' +
'<strong>Wavelength:</strong> ' + data.lambda.toFixed(3) + ' m (ch ' + data.channel + ')<br>' +
'<strong>Link health:</strong> ' + 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
var debugSection = document.getElementById('debug-section');
if (debugSection) {
debugSection.style.display = 'block';
}
// Bind layer controls to Layers module
if (window.Layers) {
var fresnelCheckbox = document.getElementById('fresnel-zones-toggle');
if (fresnelCheckbox) {
Layers.bindCheckbox(Layers.LAYERS.FRESNEL, fresnelCheckbox);
}
Layers.onLayerChange(Layers.LAYERS.FRESNEL, function(visible) {
toggleFresnelDebugOverlay(visible);
});
}
};
// 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
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();
}
}
});
})();