Add 24h migration window for legacy/unprovisioned nodes to connect while flagged as Unpaired rather than silently rejected. Surface unpaired nodes in the fleet UI with amber badge, pulsing status indicator, and migration window countdown banner. Add re-provision wizard entry point from fleet panel — clicking "Pair" on an unpaired node opens the onboarding wizard in re-provisioning mode, skipping firmware flash and going straight to serial credential provisioning. After migration window closes, nodes without valid tokens are rejected outright. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2614 lines
96 KiB
JavaScript
2614 lines
96 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
// Identify node by blinking its LED
|
|
function identifyNode(mac, durationMs) {
|
|
const payload = durationMs ? JSON.stringify({ duration_ms: durationMs }) : JSON.stringify({});
|
|
|
|
fetch(`/api/nodes/${mac}/identify`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: payload
|
|
})
|
|
.then(res => {
|
|
if (!res.ok) {
|
|
throw new Error('Identify failed: ' + res.status);
|
|
}
|
|
return res.json();
|
|
})
|
|
.then(data => {
|
|
showToast(`Identify command sent to ${mac}`, 'success');
|
|
})
|
|
.catch(err => {
|
|
console.error('[Identify] Error:', err);
|
|
showToast(`Failed to identify ${mac}: ${err.message}`, 'error');
|
|
});
|
|
}
|
|
|
|
function updatePresenceIndicator() {
|
|
let anyMotion = false;
|
|
state.links.forEach(link => {
|
|
if (link.motionDetected) anyMotion = true;
|
|
});
|
|
const el = document.getElementById('presence-indicator');
|
|
if (anyMotion) {
|
|
el.className = 'motion';
|
|
el.textContent = 'MOTION';
|
|
} else {
|
|
el.className = 'clear';
|
|
el.textContent = 'CLEAR';
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Presence Panel
|
|
// ============================================
|
|
function handlePresenceUpdate(msg) {
|
|
if (!msg.links) return;
|
|
|
|
const now = performance.now();
|
|
let anyMotion = false;
|
|
let anyStationary = false;
|
|
|
|
for (const [linkID, info] of Object.entries(msg.links)) {
|
|
// Update link state if link exists
|
|
const link = state.links.get(linkID);
|
|
const prevBreathingState = link ? link.breathingState : 'CLEAR';
|
|
if (link) {
|
|
link.motionDetected = info.is_motion || info.motion_detected || false;
|
|
link.deltaRMS = info.delta_rms || 0;
|
|
// Phase 6: Breathing/dwell state
|
|
link.breathingState = info.breathing_state || 'CLEAR';
|
|
link.breathingBPM = info.breathing_bpm || 0;
|
|
}
|
|
|
|
if (info.is_motion || info.motion_detected) anyMotion = true;
|
|
if (info.breathing_state === 'STATIONARY_DETECTED') anyStationary = true;
|
|
|
|
// Log timeline event on transition to STATIONARY_DETECTED
|
|
const newBreathingState = info.breathing_state || 'CLEAR';
|
|
if (prevBreathingState !== 'STATIONARY_DETECTED' && newBreathingState === 'STATIONARY_DETECTED') {
|
|
const bpm = info.breathing_bpm || 0;
|
|
const shortID = abbreviateLinkID(linkID);
|
|
const timeStr = new Date().toLocaleTimeString();
|
|
logTimelineEvent('stationary_detected', linkID, 'Stationary person detected on ' + shortID + ' at ' + timeStr + ' - breathing at ' + bpm.toFixed(1) + ' BPM');
|
|
}
|
|
|
|
// Append to deltaRMS history
|
|
let history = state.drHistory.get(linkID);
|
|
if (!history) {
|
|
history = [];
|
|
state.drHistory.set(linkID, history);
|
|
}
|
|
history.push({ t: now, rms: info.delta_rms || 0 });
|
|
|
|
// Trim to window
|
|
const cutoff = now - CONFIG.drTsWindowMs;
|
|
while (history.length > 0 && history[0].t < cutoff) {
|
|
history.shift();
|
|
}
|
|
if (history.length > CONFIG.drTsMaxPoints) {
|
|
history.splice(0, history.length - CONFIG.drTsMaxPoints);
|
|
}
|
|
}
|
|
|
|
updatePresencePanel(msg.links, anyMotion, anyStationary);
|
|
updateLinkList();
|
|
drawDeltaRMSTimeSeries();
|
|
}
|
|
|
|
// Timeline event logging
|
|
function logTimelineEvent(eventType, linkID, message) {
|
|
// Log to console for debugging
|
|
console.log('[Timeline]', eventType, linkID, message);
|
|
|
|
// Show toast notification for stationary detection
|
|
if (eventType === 'stationary_detected') {
|
|
showToast(message, 'info');
|
|
}
|
|
|
|
// Could also append to a timeline panel in the UI if one exists
|
|
const timelineEl = document.getElementById('timeline-events');
|
|
if (timelineEl) {
|
|
const entry = document.createElement('div');
|
|
entry.className = 'timeline-entry timeline-' + eventType;
|
|
entry.innerHTML = '<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() {
|
|
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 = (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 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 = 0.5;
|
|
if (window.Viz3D && Viz3D.getLinkHealth) {
|
|
var healthData = Viz3D.getLinkHealth(linkID);
|
|
if (healthData) healthScore = healthData.score;
|
|
}
|
|
|
|
var txNode = state.nodes.get(data.txMAC);
|
|
var rxNode = state.nodes.get(data.rxMAC);
|
|
var txLabel = txNode ? (txNode.name || txNode.mac) : data.txMAC;
|
|
var rxLabel = rxNode ? (rxNode.name || rxNode.mac) : data.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';
|
|
}
|
|
};
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
});
|
|
})();
|