spaxel/dashboard/js/app.js
jedarden fb691904c6 feat(dashboard): per-link motion presence indicator with amplitude time series
- Dashboard hub broadcasts motion state changes immediately on transition
  (idle↔motion) via BroadcastMotionState; periodic state snapshots include
  motion_states for new client init
- Per-link presence badge (green CLEAR / red MOTION) rendered in link list
  alongside global presence indicator in status bar
- Amplitude mean time-series chart (60 s rolling window) for selected link,
  line segments colored by motion state at each sample
- Fix: links created from JSON link_active/state events now initialize
  ampHistory and lastAmpSample so time-series accumulates from first frame

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:55:33 -04:00

682 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = {
wsReconnectDelay: 3000,
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
};
// ============================================
// State
// ============================================
const state = {
ws: null,
wsConnected: false,
nodes: new Map(), // MAC -> { mac, firmware, chip, lastSeen }
links: new Map(), // linkID -> { nodeMAC, peerMAC, lastFrame, lastCSI, motionDetected, deltaRMS, ampHistory, lastAmpSample }
selectedLinkID: null,
lastChartUpdate: 0,
frameCount: 0,
lastFpsTime: performance.now()
};
// ============================================
// Three.js Scene Setup
// ============================================
let scene, camera, renderer, controls, gridHelper, axesHelper;
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
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
// 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
// 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);
console.log('[Spaxel] Scene initialized');
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
updateFPS();
}
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
// ============================================
function connectWebSocket() {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsURL = `${wsProtocol}//${window.location.host}/ws/dashboard`;
console.log('[Spaxel] Connecting to', wsURL);
state.ws = new WebSocket(wsURL);
state.ws.binaryType = 'arraybuffer';
state.ws.onopen = function() {
console.log('[Spaxel] WebSocket connected');
state.wsConnected = true;
updateConnectionStatus(true);
};
state.ws.onclose = function(event) {
console.log('[Spaxel] WebSocket closed:', event.code, event.reason);
state.wsConnected = false;
updateConnectionStatus(false);
scheduleReconnect();
};
state.ws.onerror = function(error) {
console.error('[Spaxel] WebSocket error:', error);
};
state.ws.onmessage = function(event) {
handleMessage(event.data);
};
}
function scheduleReconnect() {
console.log('[Spaxel] Reconnecting in', CONFIG.wsReconnectDelay, 'ms');
setTimeout(connectWebSocket, CONFIG.wsReconnectDelay);
}
function updateConnectionStatus(connected) {
const dot = document.getElementById('ws-status');
const text = document.getElementById('ws-status-text');
if (connected) {
dot.classList.remove('disconnected');
dot.classList.add('connected');
text.textContent = 'Connected';
} else {
dot.classList.remove('connected');
dot.classList.add('disconnected');
text.textContent = 'Disconnected';
}
}
// ============================================
// Message Handling
// ============================================
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) {
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();
break;
case 'node_connected':
state.nodes.set(msg.mac, {
mac: msg.mac,
firmware: msg.firmware_version,
chip: msg.chip,
lastSeen: Date.now()
});
updateNodeList();
break;
case 'node_disconnected':
state.nodes.delete(msg.mac);
updateNodeList();
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();
}
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;
default:
// Ignore unknown types (forward-compatible)
}
}
// 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;
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 isOnline = Date.now() - node.lastSeen < 30000;
html += `
<div class="node-item" data-mac="${mac}">
<span class="node-mac">${mac}</span>
<span class="node-status ${isOnline ? 'online' : 'offline'}">
${isOnline ? 'Online' : 'Offline'}
</span>
</div>
`;
});
container.innerHTML = html;
}
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';
}
}
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 || []);
}
}
// ============================================
// Amplitude Chart (Canvas 2D) + Time Series
// ============================================
let chartCanvas, chartCtx;
let tsCanvas, tsCtx;
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([]);
}
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);
}
// ============================================
// Initialization
// ============================================
function init() {
console.log('[Spaxel] Dashboard initializing...');
initScene();
initChart();
connectWebSocket();
animate();
console.log('[Spaxel] Dashboard ready');
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();