spaxel/dashboard/js/app.js
jedarden 97f1eafc6f feat: process buffered events from delta WebSocket updates
Events (zone entries/exits, portal crossings, presence transitions)
were already broadcast immediately via BroadcastEvent, but the
buffered copies included in the 10 Hz delta tick were silently
dropped by handleIncrementalUpdate. Now delta events are processed
through the same handleEventMessage path, with dedup to avoid
double-processing when both immediate and delta copies arrive.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 11:04:49 -04:00

1680 lines
59 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
healthPollIntervalMs: 10000, // poll /api/links every 10 seconds
diurnalPollIntervalMs: 30000 // poll /api/diurnal/status every 30 seconds
};
// ============================================
// 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(),
// 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
};
// ============================================
// 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);
// Initialise placement (TransformControls, GDOP, room editor)
if (window.Placement) {
Placement.init(scene, camera, renderer, 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();
if (window.Placement) Placement.update();
renderer.render(scene, camera);
updateFPS();
}
// ============================================
// 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;
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);
// 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
// ============================================
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;
state.awaitingSnapshot = 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
// ============================================
// Event handlers for new message types
function handleEventMessage(msg) {
if (!msg.event) return;
const event = msg.event;
// Dedup: skip if we already processed this event (from immediate broadcast or prior delta)
const eid = String(event.id);
if (state.recentEventIDs.has(eid)) return;
state.recentEventIDs.add(eid);
// Prune old IDs every 30 seconds to prevent unbounded growth
const now = Date.now();
if (now > state.recentEventIDsPruneAt) {
state.recentEventIDs.clear();
state.recentEventIDsPruneAt = now + 30000;
}
console.log('[Spaxel] Event:', event.kind, 'in', event.zone, 'by', event.person_name || 'blob #' + event.blob_id);
// Log to timeline
const timeStr = new Date(event.ts).toLocaleTimeString();
let description = '';
if (event.kind === 'zone_entry') {
description = (event.person_name || 'Someone') + ' entered ' + event.zone;
} else if (event.kind === 'zone_exit') {
description = (event.person_name || 'Someone') + ' left ' + event.zone;
} else if (event.kind === 'portal_crossing') {
description = (event.person_name || 'Someone') + ' crossed portal in ' + event.zone;
} else if (event.kind === 'presence_transition') {
description = (event.person_name || 'Someone') + ' presence detected in ' + event.zone;
} else {
description = event.kind + ' in ' + event.zone;
}
logTimelineEvent(event.kind, null, description + ' (' + timeStr + ')');
// Show toast for security-relevant events
if (event.kind === 'zone_entry' || event.kind === 'portal_crossing') {
showToast(description, 'info');
}
}
function handleAlertMessage(msg) {
if (!msg.alert) return;
const alert = msg.alert;
console.log('[Spaxel] Alert:', alert.severity, alert.description);
// Show toast notification
const toastType = alert.severity === 'critical' ? 'error' : 'warning';
showToast(alert.description, toastType);
// Log to timeline
const timeStr = new Date(alert.ts).toLocaleTimeString();
logTimelineEvent('alert', null, '[' + alert.severity.toUpperCase() + '] ' + alert.description + ' (' + timeStr + ')');
// Could trigger UI alert state here (e.g., show alert banner)
if (window.showAlertBanner) {
window.showAlertBanner(alert);
}
}
function handleBLEScanMessage(msg) {
if (!msg.devices || !Array.isArray(msg.devices)) return;
console.log('[Spaxel] BLE scan: ' + msg.devices.length + ' devices');
// Update BLE device list state
if (!state.bleDevices) {
state.bleDevices = new Map();
}
// Clear previous entries and add current devices
state.bleDevices.clear();
msg.devices.forEach(function (device) {
state.bleDevices.set(device.mac || device.addr, {
mac: device.mac || device.addr,
name: device.name || device.device_name || 'Unknown',
rssi: device.rssi || device.rssi_dbm || 0,
last_seen: device.last_seen || Date.now(),
label: device.label || '',
blob_id: device.blob_id || null
});
});
// Update UI if BLE panel exists
if (window.BLEPanel && window.BLEPanel.updateDevices) {
window.BLEPanel.updateDevices(msg.devices);
}
}
function handleTriggerStateMessage(msg) {
if (!msg.trigger) return;
const trigger = msg.trigger;
console.log('[Spaxel] Trigger state:', trigger.name, 'enabled=' + trigger.enabled);
// Update trigger state in UI if automation panel exists
if (window.Automations && window.Automations.updateTriggerState) {
window.Automations.updateTriggerState(trigger);
}
}
function handleSystemHealthMessage(msg) {
if (!msg.health) return;
const health = msg.health;
// Update system health display in UI
const healthEl = document.getElementById('system-uptime');
if (healthEl) {
const uptimeSec = health.uptime_s || 0;
const days = Math.floor(uptimeSec / 86400);
const hours = Math.floor((uptimeSec % 86400) / 3600);
const mins = Math.floor((uptimeSec % 3600) / 60);
if (days > 0) {
healthEl.textContent = days + 'd ' + hours + 'h ' + mins + 'm';
} else {
healthEl.textContent = hours + 'h ' + mins + 'm';
}
}
const memEl = document.getElementById('system-memory');
if (memEl) {
memEl.textContent = (health.mem_mb || 0).toFixed(1) + ' MB';
}
const goroutinesEl = document.getElementById('system-goroutines');
if (goroutinesEl) {
goroutinesEl.textContent = health.go_routines || 0;
}
}
function handleMessage(data) {
if (typeof data === 'string') {
// JSON message
try {
const msg = JSON.parse(data);
handleJSONMessage(msg);
} catch (e) {
console.error('[Spaxel] Failed to parse JSON:', e);
}
} else if (data instanceof ArrayBuffer) {
// Binary CSI frame
handleBinaryFrame(data);
}
}
function handleJSONMessage(msg) {
// Snapshot: first message on every connect/reconnect. Contains full state.
if (msg.type === 'snapshot') {
handleSnapshot(msg);
return;
}
// Incremental update: 10 Hz delta with no type field.
// Only fields that changed since last tick are present.
if (!msg.type) {
handleIncrementalUpdate(msg);
return;
}
switch (msg.type) {
case 'state':
// Initial state dump
if (msg.nodes) {
msg.nodes.forEach(node => {
state.nodes.set(node.mac, {
mac: node.mac,
firmware: node.firmware_version,
chip: node.chip,
lastSeen: Date.now()
});
});
}
if (msg.links) {
msg.links.forEach(link => {
const existing = state.links.get(link.id) || {};
state.links.set(link.id, {
nodeMAC: link.node_mac,
peerMAC: link.peer_mac,
lastFrame: Date.now(),
lastCSI: existing.lastCSI || null,
motionDetected: existing.motionDetected || false,
deltaRMS: existing.deltaRMS || 0,
ampHistory: existing.ampHistory || [],
lastAmpSample: existing.lastAmpSample || 0
});
});
}
if (msg.motion_states) {
msg.motion_states.forEach(ms => applyMotionState(ms));
}
updateNodeList();
updateLinkList();
Viz3D.applyLinks(msg.links || []);
break;
case 'node_connected':
state.nodes.set(msg.mac, {
mac: msg.mac,
firmware: msg.firmware_version,
chip: msg.chip,
lastSeen: Date.now()
});
updateNodeList();
if (window.SpaxelTroubleshoot) {
window.SpaxelTroubleshoot.handleEvent('node_connected', msg);
}
// Show first-time tooltips on first node connection
if (window.SpaxelTooltips && state.nodes.size === 1) {
setTimeout(function () { window.SpaxelTooltips.showSequence(); }, 2000);
}
break;
case 'node_disconnected':
state.nodes.delete(msg.mac);
updateNodeList();
if (window.SpaxelTroubleshoot) {
window.SpaxelTroubleshoot.handleEvent('node_disconnected', msg);
}
break;
case 'link_active':
if (!state.links.has(msg.id)) {
state.links.set(msg.id, {
nodeMAC: msg.node_mac,
peerMAC: msg.peer_mac,
lastFrame: Date.now(),
lastCSI: null,
motionDetected: false,
deltaRMS: 0,
ampHistory: [],
lastAmpSample: 0
});
updateLinkList();
}
Viz3D.handleLinkActive(msg);
break;
case 'link_inactive':
state.links.delete(msg.id);
updateLinkList();
Viz3D.handleLinkInactive(msg);
break;
case 'motion_state':
// Targeted broadcast on state change
if (msg.links) {
let changed = false;
msg.links.forEach(ms => {
if (applyMotionState(ms)) changed = true;
});
if (changed) updateLinkList();
}
break;
case 'presence_update':
handlePresenceUpdate(msg);
break;
case 'registry_state':
Viz3D.handleRegistryState(msg);
if (window.Placement) Placement.handleRegistryState(msg);
// Merge virtual nodes into local state and refresh node list
if (msg.nodes) {
var registryMACs = new Set(msg.nodes.map(function (n) { return n.mac; }));
msg.nodes.forEach(function (node) {
if (!state.nodes.has(node.mac)) {
state.nodes.set(node.mac, {
mac: node.mac,
firmware: '',
chip: '',
lastSeen: 0,
virtual: !!node.virtual
});
} else {
var existing = state.nodes.get(node.mac);
if (node.virtual) existing.virtual = true;
}
});
// Remove virtual nodes no longer in registry
state.nodes.forEach(function (node, mac) {
if (!registryMACs.has(mac) && node.virtual) {
state.nodes.delete(mac);
}
});
updateNodeList();
}
break;
case 'loc_update':
Viz3D.handleLocUpdate(msg);
break;
case 'link_health':
// Health score update from server
if (msg.links) {
handleLinkHealthUpdate(msg.links);
}
break;
case 'diurnal_ready':
// Diurnal patterns learned notification
showToast(msg.message || 'Daily patterns learned! Detection accuracy improved.', 'success');
// Refresh diurnal status
fetchDiurnalStatus();
break;
case 'event':
// Event: presence transition, zone entry/exit, portal crossing
handleEventMessage(msg);
break;
case 'alert':
// Alert: anomaly detection, security mode trigger
handleAlertMessage(msg);
break;
case 'ble_scan':
// BLE device list update (5s interval)
handleBLEScanMessage(msg);
break;
case 'trigger_state':
// Automation trigger state change
handleTriggerStateMessage(msg);
break;
case 'system_health':
// System health stats (60s interval)
handleSystemHealthMessage(msg);
break;
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;
console.log('[Spaxel] Received snapshot, rebuilding state');
// 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);
}
}
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);
}
}
// Events buffered since last tick (presence transitions, zone entries/exits, portal crossings)
if (msg.events && Array.isArray(msg.events)) {
msg.events.forEach(function (evt) {
handleEventMessage({ type: 'event', event: evt });
});
}
}
// applyMotionState updates a link's motion fields; returns true if it changed.
function applyMotionState(ms) {
const link = state.links.get(ms.link_id);
if (!link) return false;
const prev = link.motionDetected;
link.motionDetected = ms.motion_detected;
link.deltaRMS = ms.delta_rms || 0;
// Phase 6: Breathing/dwell state
link.breathingState = ms.breathing_state || 'CLEAR';
link.breathingBPM = ms.breathing_bpm || 0;
return prev !== ms.motion_detected;
}
function handleBinaryFrame(buffer) {
const frame = parseCSIFrame(buffer);
if (!frame) return;
const linkID = frame.linkID;
// Update link state
let link = state.links.get(linkID);
if (!link) {
link = {
nodeMAC: frame.nodeMAC,
peerMAC: frame.peerMAC,
lastFrame: Date.now(),
lastCSI: null,
motionDetected: false,
deltaRMS: 0,
ampHistory: [],
lastAmpSample: 0
};
state.links.set(linkID, link);
updateLinkList();
} else {
link.lastFrame = Date.now();
// Ensure time-series fields exist on links pre-created from JSON events
if (!link.ampHistory) {
link.ampHistory = [];
link.lastAmpSample = 0;
}
}
// Store CSI for chart rendering
link.lastCSI = frame;
// Push amplitude sample to time series (rate-limited)
const now = performance.now();
if (now - link.lastAmpSample >= CONFIG.tsMinIntervalMs) {
link.lastAmpSample = now;
let sum = 0;
for (let i = 0; i < frame.subcarriers.length; i++) {
sum += frame.subcarriers[i].amplitude;
}
const meanAmp = frame.subcarriers.length > 0 ? sum / frame.subcarriers.length : 0;
link.ampHistory.push({ t: now, amp: meanAmp, motion: link.motionDetected });
if (link.ampHistory.length > CONFIG.tsMaxPoints) {
link.ampHistory.shift();
}
}
// Update charts if this is the selected link
if (state.selectedLinkID === linkID) {
if (now - state.lastChartUpdate >= CONFIG.chartUpdateMs) {
drawAmplitudeChart(frame);
drawTimeSeries(link.ampHistory);
state.lastChartUpdate = now;
}
}
}
// ============================================
// CSI Frame Parsing (matches Go binary format)
// ============================================
function parseCSIFrame(buffer) {
const view = new DataView(buffer);
const bytes = new Uint8Array(buffer);
if (bytes.length < 24) {
return null; // Header too short
}
const nodeMAC = formatMAC(bytes, 0);
const peerMAC = formatMAC(bytes, 6);
const timestampUS = view.getBigUint64(12, true); // little-endian
const rssi = view.getInt8(20);
const noiseFloor = view.getInt8(21);
const channel = bytes[22];
const nSub = bytes[23];
if (channel === 0 || channel > 14) {
return null; // Invalid channel
}
const expectedLen = 24 + nSub * 2;
if (bytes.length !== expectedLen) {
return null; // Payload length mismatch
}
// Extract I/Q pairs and compute amplitude
const subcarriers = [];
for (let i = 0; i < nSub; i++) {
const offset = 24 + i * 2;
const iVal = bytes[offset];
const qVal = bytes[offset + 1];
// Convert from unsigned to signed (JavaScript quirk)
const I = iVal > 127 ? iVal - 256 : iVal;
const Q = qVal > 127 ? qVal - 256 : qVal;
const amplitude = Math.sqrt(I * I + Q * Q);
subcarriers.push({ I, Q, amplitude });
}
return {
nodeMAC,
peerMAC,
linkID: `${nodeMAC}:${peerMAC}`,
timestampUS: Number(timestampUS),
rssi,
noiseFloor,
channel,
nSub,
subcarriers
};
}
function formatMAC(bytes, offset) {
const parts = [];
for (let i = 0; i < 6; i++) {
parts.push(bytes[offset + i].toString(16).padStart(2, '0').toUpperCase());
}
return parts.join(':');
}
// ============================================
// UI Updates
// ============================================
function updateNodeList() {
const container = document.getElementById('node-list');
document.getElementById('node-count').textContent = state.nodes.size;
if (state.nodes.size === 0) {
container.innerHTML = '<div class="empty-state">No nodes connected</div>';
return;
}
let html = '';
state.nodes.forEach((node, mac) => {
const isVirtual = !!node.virtual;
const isOnline = isVirtual || Date.now() - node.lastSeen < 30000;
const statusClass = isVirtual ? 'virtual' : (isOnline ? 'online' : 'offline');
const statusLabel = isVirtual ? 'Virtual' : (isOnline ? 'Online' : 'Offline');
// Check for OTA rollback state
let rollbackBadge = '';
let otaBadge = '';
if (window.SpaxelOTA) {
const otaProgress = SpaxelOTA.getProgress();
if (otaProgress && otaProgress[mac]) {
const p = otaProgress[mac];
if (p.state === 'rollback') {
rollbackBadge = '<span class="node-rollback-badge">ROLLBACK</span>';
} else if (p.state === 'downloading' || p.state === 'rebooting') {
otaBadge = '<span class="node-ota-badge">OTA ' + (p.progress_pct || 0) + '%</span>';
} else if (p.state === 'verified') {
otaBadge = '<span class="node-verified-badge">UPDATED</span>';
}
}
}
// Firmware version display (shortened)
const fwDisplay = node.firmware ? '<span class="node-fw">' + escapeHtml(node.firmware) + '</span>' : '';
html += `
<div class="node-item" data-mac="${mac}">
<span class="node-mac">${mac}</span>
${fwDisplay}
${rollbackBadge}
${otaBadge}
<span class="node-status ${statusClass}">
${statusLabel}
</span>
</div>
`;
});
container.innerHTML = html;
// Click-to-select for placement
container.querySelectorAll('.node-item').forEach(function (el) {
el.addEventListener('click', function () {
if (window.Placement) Placement.selectNode(el.dataset.mac);
});
});
}
function escapeHtml(s) {
if (!s) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 || []);
}
}
// ============================================
// 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();
startHealthPolling();
startDiurnalPolling();
animate();
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
};
// ============================================
// Crowd Flow Visualization Controls
// Global wrappers for HTML onchange handlers -> Viz3D module
// ============================================
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);
};
})();