spaxel/dashboard/js/app.js
jedarden 75edd8339a feat(dashboard): presence panel with per-link motion indicators and deltaRMS time series
Add 500ms periodic presence_update broadcast from the dashboard hub with
per-link motion state (is_motion, delta_rms, confidence). Surface this in
a new Presence panel on the dashboard with coloured dot indicators
(green=clear, amber=motion, red=high-confidence) and deltaRMS values.
Includes a rolling 10s Canvas 2D line chart of deltaRMS with a threshold
line at 0.02 for the selected link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 23:49:08 -04:00

925 lines
31 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 = {
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
drTsWindowMs: 10000, // deltaRMS time series window: 10 seconds
drTsMaxPoints: 100, // max deltaRMS samples per link
drThreshold: 0.02 // DefaultDeltaRMSThreshold
};
// ============================================
// 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,
presenceSelectedLinkID: null,
drHistory: new Map(), // linkID -> [{ t: number, rms: number }]
lastChartUpdate: 0,
frameCount: 0,
lastFpsTime: performance.now()
};
// ============================================
// Three.js Scene Setup
// ============================================
let scene, camera, renderer, controls, gridHelper, axesHelper, clock;
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);
// Clock for animation mixer delta
clock = new THREE.Clock();
// Initialise 3-D spatial visualisation layer
Viz3D.init(scene, camera, controls);
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();
Viz3D.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();
Viz3D.applyLinks(msg.links || []);
break;
case 'node_connected':
state.nodes.set(msg.mac, {
mac: msg.mac,
firmware: msg.firmware_version,
chip: msg.chip,
lastSeen: Date.now()
});
updateNodeList();
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();
}
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);
break;
case 'loc_update':
Viz3D.handleLocUpdate(msg);
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';
}
}
// ============================================
// Presence Panel
// ============================================
function handlePresenceUpdate(msg) {
if (!msg.links) return;
const now = performance.now();
let anyMotion = false;
for (const [linkID, info] of Object.entries(msg.links)) {
// Update link state if link exists
const link = state.links.get(linkID);
if (link) {
link.motionDetected = info.is_motion || info.motion_detected || false;
link.deltaRMS = info.delta_rms || 0;
}
if (info.is_motion || info.motion_detected) anyMotion = true;
// 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);
updateLinkList();
drawDeltaRMSTimeSeries();
}
function updatePresencePanel(links, anyMotion) {
const container = document.getElementById('presence-list');
const statusEl = document.getElementById('presence-status');
if (anyMotion) {
statusEl.className = 'motion';
statusEl.textContent = 'MOTION';
} 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 shortID = abbreviateLinkID(linkID);
const selected = state.presenceSelectedLinkID === linkID ? 'selected' : '';
let dotClass = 'clear';
if (isMotion && confidence > 0.7) {
dotClass = 'high-confidence';
} else if (isMotion) {
dotClass = 'motion';
}
html += `
<div class="presence-row ${selected}" data-link-id="${linkID}">
<span class="presence-dot ${dotClass}"></span>
<span class="presence-link-id">${shortID}</span>
<span class="presence-rms">${rms.toFixed(4)}</span>
</div>
`;
}
container.innerHTML = html;
container.querySelectorAll('.presence-row').forEach(el => {
el.addEventListener('click', () => {
state.presenceSelectedLinkID = el.dataset.linkId;
updatePresencePanel(links, anyMotion);
});
});
}
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 || []);
}
}
// ============================================
// 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);
}
// ============================================
// 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();
}
})();