spaxel/dashboard/js/app.js
jedarden aaa622d410 feat(ui): implement command palette (Component 34) with tests
- commandpalette.js: CommandPaletteManager with fuzzy scorer, time parsing,
  command registry (20 commands), recent history, entity search, mode gating
- commandpalette.css: modal overlay, search input, result list styles
- commandpalette.test.js: 64 tests covering fuzzy match, time parsing, commands
  completeness, keyboard nav, recent history, expert-mode gating, positioning
- app.js: spaxelGetState() exposure and Ctrl+K fallback listener
- index.html: includes commandpalette.css and commandpalette.js
- explainability.js + explain.go: detection explainability backend/frontend
- hub.go + server.go: dashboard WebSocket and API updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:51:16 -04:00

2573 lines
94 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()) 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 = '<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 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 = '<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...');
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() {
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 =
'<strong>Link:</strong> ' + abbreviateLinkID(linkID) + '<br>' +
'<strong>TX:</strong> ' + txLabel + '<br>' +
'<strong>RX:</strong> ' + rxLabel + '<br>' +
'<strong>Fresnel radius at midpoint:</strong> ' + data.b.toFixed(3) + ' 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 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();
}
}
});
})();