spaxel/dashboard/js/ambient_renderer.js
jedarden d81d1cb82c feat: implement ambient dashboard mode with Canvas 2D renderer
- Added /ambient route serving ambient.html for wall-mounted tablet display
- Canvas 2D renderer at 2Hz with lerp interpolation for smooth person movement
- Time-of-day palette with 30-minute transitions (morning/day/evening/night)
- Auto-dim: reduces brightness to 40% after 60s of no presence
- Alert mode: pulsing red background for fall/security alerts
- Morning briefing overlay: 15-second overlay on first detection after 6am
- Unified alerts API for fall, anomaly, and node_offline events
- Jest test setup mocking Canvas 2D context for jsdom

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 23:16:52 -04:00

835 lines
26 KiB
JavaScript

/**
* Spaxel Dashboard - Ambient Mode Canvas 2D Renderer
*
* Dedicated Canvas 2D rendering engine for ambient display mode.
* Renders at 2 Hz (one frame every 500ms) for minimal CPU usage.
* Uses lerp-interpolated positions for smooth person movement.
*/
(function() {
'use strict';
// ============================================
// Configuration
// ============================================
const RENDER_INTERVAL_MS = 500; // 2 Hz = one frame every 500ms
const LERP_FACTOR = 0.2; // 20% of remaining distance per frame
const AUTO_DIM_TIMEOUT_MS = 60000; // 60 seconds of no presence in ambient zone
const ALERT_PULSE_INTERVAL_MS = 1000; // 1 Hz pulse for alert mode
// Time-of-day palette colors
const TIME_COLORS = {
morning: { bg: '#f0f4f8', text: '#1a365d', accent: '#4299e1' }, // 6-10am
day: { bg: '#ffffff', text: '#1d1d1f', accent: '#0066cc' }, // 10am-6pm
evening: { bg: '#1c1507', text: '#fef3e7', accent: '#ed8936' }, // 6-10pm
night: { bg: '#040404', text: '#e0e0e0', accent: '#4fc3f7' } // 10pm-6am
};
// ============================================
// State
// ============================================
let canvas = null;
let ctx = null;
let renderTimer = null;
let lastRenderTime = 0;
let dimTimer = null;
let isDimmed = false;
let alertPulseTimer = null;
let alertPulseState = false; // for pulsing animation
let renderCallCount = 0; // Track number of renderFrame calls (for testing)
// Current state
let currentState = {
zones: [],
blobs: [],
portals: [],
nodes: [],
systemHealth: 'unknown', // 'healthy', 'degraded', 'offline'
securityMode: false,
alerts: [],
lastUpdate: null
};
// Target positions for lerp interpolation (blobId -> {x, y, z})
let targetPositions = new Map();
// Current interpolated positions (blobId -> {x, y, z})
let currentPositions = new Map();
// Expose internal state for testing
function _getCurrentState() {
return currentState;
}
function _getCurrentPositions() {
return currentPositions;
}
function _getTargetPositions() {
return targetPositions;
}
function _getAlertPulseState() {
return alertPulseState;
}
function _getRenderCallCount() {
return renderCallCount;
}
function _resetRenderCallCount() {
renderCallCount = 0;
}
function _enterDimMode() {
enterDimMode();
}
function _checkAmbientZonePresence() {
checkAmbientZonePresence();
}
// Configuration
let config = {
ambientZone: null, // Zone ID for auto-dim detection
scale: 50, // Pixels per meter
margin: 40 // Canvas margin in pixels
};
// Callbacks
let onAlertClick = null;
let onUserActivity = null;
// ============================================
// Public API
// ============================================
const AmbientRenderer = {
/**
* Initialize the renderer
* @param {HTMLCanvasElement} canvasElement - The canvas element to render to
* @param {Object} rendererConfig - Configuration options
*/
init(canvasElement, rendererConfig = {}) {
canvas = canvasElement;
ctx = canvas.getContext('2d');
// Apply configuration
Object.assign(config, rendererConfig);
// Set up canvas
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// Set up canvas interaction
setupCanvasInteraction();
// Start render loop
startRenderLoop();
// Start auto-dim timer
resetDimTimer();
console.log('[AmbientRenderer] Initialized');
},
/**
* Update the current state
* @param {Object} state - New state from WebSocket
*/
updateState(state) {
// Update target positions for lerp
if (state.blobs) {
state.blobs.forEach(blob => {
const target = {
x: blob.x,
y: blob.y,
z: blob.z || 0
};
targetPositions.set(blob.id, target);
// Initialize current position if this is a new blob
if (!currentPositions.has(blob.id)) {
currentPositions.set(blob.id, { ...target });
}
});
// Remove blobs that are no longer tracked
const trackedIds = new Set(state.blobs.map(b => b.id));
for (const id of currentPositions.keys()) {
if (!trackedIds.has(id)) {
currentPositions.delete(id);
targetPositions.delete(id);
}
}
}
// Update state
if (state.zones) currentState.zones = state.zones;
if (state.portals) currentState.portals = state.portals;
if (state.nodes) currentState.nodes = state.nodes;
if (state.alerts) currentState.alerts = state.alerts;
if (state.security_mode !== undefined) currentState.securityMode = state.security_mode;
currentState.lastUpdate = new Date();
// Check for alerts to update system health
updateSystemHealth();
// Check for presence in ambient zone (for auto-dim)
checkAmbientZonePresence();
},
/**
* Set the ambient zone for auto-dim detection
* @param {string} zoneId - Zone ID to monitor for presence
*/
setAmbientZone(zoneId) {
config.ambientZone = zoneId;
console.log('[AmbientRenderer] Ambient zone set to:', zoneId);
},
/**
* Set alert click callback
* @param {Function} callback - Function to call when alert is clicked
*/
setAlertClickCallback(callback) {
onAlertClick = callback;
},
/**
* Set user activity callback
* @param {Function} callback - Function to call on user activity
*/
setUserActivityCallback(callback) {
onUserActivity = callback;
},
/**
* Manually trigger a render
*/
render() {
if (ctx && canvas) {
renderFrame();
}
},
/**
* Enter alert mode
* @param {Object} alert - Alert data
*/
enterAlertMode(alert) {
currentState.alerts = [alert];
startAlertPulse();
},
/**
* Exit alert mode
*/
exitAlertMode() {
currentState.alerts = [];
stopAlertPulse();
},
/**
* Wake from dim mode
*/
wakeFromDim() {
if (isDimmed) {
isDimmed = false;
canvas.style.filter = 'brightness(1)';
canvas.style.transition = 'filter 0.3s ease';
resetDimTimer();
}
},
/**
* Get current time period
* @returns {string} - 'morning', 'day', 'evening', or 'night'
*/
getTimePeriod() {
const hour = new Date().getHours();
if (hour >= 6 && hour < 10) return 'morning';
if (hour >= 10 && hour < 18) return 'day';
if (hour >= 18 && hour < 22) return 'evening';
return 'night';
},
/**
* Clean up resources
*/
destroy() {
stopRenderLoop();
stopAlertPulse();
if (dimTimer) {
clearTimeout(dimTimer);
dimTimer = null;
}
window.removeEventListener('resize', resizeCanvas);
console.log('[AmbientRenderer] Destroyed');
},
/**
* Stop the render loop (for testing)
*/
stopRenderLoop() {
stopRenderLoop();
},
/**
* Start the render loop (for testing)
*/
startRenderLoop() {
startRenderLoop();
},
// Testing/internal methods
_getCurrentState,
_getCurrentPositions,
_getTargetPositions,
_getAlertPulseState,
_getRenderCallCount,
_resetRenderCallCount,
_enterDimMode,
_checkAmbientZonePresence
};
// ============================================
// Internal Functions
// ============================================
function resizeCanvas() {
if (!canvas) return;
const container = canvas.parentElement;
if (!container) return;
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
if (ctx) {
ctx.scale(dpr, dpr);
}
}
function startRenderLoop() {
stopRenderLoop();
function renderLoop(timestamp) {
// Throttle to 2 Hz (500ms between frames)
if (timestamp - lastRenderTime >= RENDER_INTERVAL_MS) {
lastRenderTime = timestamp;
renderFrame();
}
renderTimer = requestAnimationFrame(renderLoop);
}
renderTimer = requestAnimationFrame(renderLoop);
}
function stopRenderLoop() {
if (renderTimer) {
cancelAnimationFrame(renderTimer);
renderTimer = null;
}
}
function renderFrame() {
if (!ctx || !canvas) return;
renderCallCount++; // Track render calls for testing
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Get current time period and colors
const period = AmbientRenderer.getTimePeriod();
const colors = TIME_COLORS[period];
// Draw background
drawBackground(ctx, width, height, colors);
// Check for alert mode
const hasActiveAlert = currentState.alerts.length > 0;
if (hasActiveAlert) {
drawAlertMode(ctx, width, height);
return; // Alert mode takes over the entire canvas
}
// Calculate floor plan bounds
const bounds = calculateBounds(width, height);
// Draw zones (room outlines)
drawZones(ctx, bounds, colors);
// Draw portals
drawPortals(ctx, bounds, colors);
// Draw nodes
drawNodes(ctx, bounds, colors);
// Draw people (with lerp-interpolated positions)
drawPeople(ctx, bounds, colors);
// Draw system status indicator (top-left)
drawSystemStatus(ctx, colors);
// Draw time display (top-right)
drawTimeDisplay(ctx, width, colors);
}
function drawBackground(ctx, width, height, colors) {
// Solid background color based on time of day
ctx.fillStyle = colors.bg;
ctx.fillRect(0, 0, width, height);
}
function drawAlertMode(ctx, width, height) {
// Pulsing red background for alert mode
const pulseColor = alertPulseState ? '#dc2626' : '#991b1b';
ctx.fillStyle = pulseColor;
ctx.fillRect(0, 0, width, height);
// Draw alert text
const alert = currentState.alerts[0];
if (alert) {
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 48px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const title = alert.type === 'fall_alert' ? 'FALL DETECTED' : 'ALERT';
const message = formatAlertMessage(alert);
ctx.fillText(title, width / 2, height / 2 - 30);
ctx.font = '24px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.fillText(message, width / 2, height / 2 + 30);
// Draw acknowledge button
const buttonWidth = 200;
const buttonHeight = 60;
const buttonX = (width - buttonWidth) / 2;
const buttonY = height / 2 + 80;
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.roundRect(buttonX, buttonY, buttonWidth, buttonHeight, 8);
ctx.fill();
ctx.fillStyle = '#dc2626';
ctx.font = 'bold 20px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.fillText('Acknowledge', width / 2, buttonY + buttonHeight / 2);
}
}
function formatAlertMessage(alert) {
if (alert.type === 'fall_alert') {
const person = alert.person || 'Someone';
return `${person} has fallen`;
} else if (alert.type === 'anomaly') {
return 'Unusual activity detected';
}
return 'Alert detected';
}
function calculateBounds(canvasWidth, canvasHeight) {
// Find bounds of all zones
if (currentState.zones.length === 0) {
// Default bounds - centered square
const size = Math.min(canvasWidth, canvasHeight) - config.margin * 2;
return {
x: (canvasWidth - size) / 2,
y: (canvasHeight - size) / 2,
width: size,
height: size,
scale: size / 10, // 10 meters default
minX: 0,
minY: 0
};
}
let minX = Infinity, minY = Infinity;
let maxX = -Infinity, maxY = -Infinity;
currentState.zones.forEach(zone => {
const x = zone.x || zone.MinX || 0;
const y = zone.y || zone.MinY || 0;
const w = zone.w || zone.SizeX || zone.w || 1;
const d = zone.d || zone.SizeY || zone.d || 1;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + w);
maxY = Math.max(maxY, y + d);
});
// Add margin
const marginMeters = 1; // 1 meter margin
minX -= marginMeters;
minY -= marginMeters;
maxX += marginMeters;
maxY += marginMeters;
const floorWidth = maxX - minX;
const floorHeight = maxY - minY;
// Calculate scale to fit canvas
const scaleX = (canvasWidth - config.margin * 2) / floorWidth;
const scaleY = (canvasHeight - config.margin * 2) / floorHeight;
const scale = Math.min(scaleX, scaleY, config.scale);
// Calculate centered bounds
const boundsWidth = floorWidth * scale;
const boundsHeight = floorHeight * scale;
const boundsX = (canvasWidth - boundsWidth) / 2;
const boundsY = (canvasHeight - boundsHeight) / 2;
return {
x: boundsX,
y: boundsY,
width: boundsWidth,
height: boundsHeight,
scale: scale,
minX: minX,
minY: minY
};
}
function worldToScreen(wx, wy, bounds) {
return {
x: bounds.x + (wx - bounds.minX) * bounds.scale,
y: bounds.y + (wy - bounds.minY) * bounds.scale
};
}
function drawZones(ctx, bounds, colors) {
currentState.zones.forEach(zone => {
const x = zone.x || zone.MinX || 0;
const y = zone.y || zone.MinY || 0;
const w = zone.w || zone.SizeX || 1;
const d = zone.d || zone.SizeY || 1;
const topLeft = worldToScreen(x, y, bounds);
const width = w * bounds.scale;
const height = d * bounds.scale;
// Zone outline (white, 1px stroke)
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.strokeRect(topLeft.x, topLeft.y, width, height);
// Zone label at centroid
const centerX = topLeft.x + width / 2;
const centerY = topLeft.y + height / 2;
const count = zone.count || zone.occupancy || 0;
const zoneName = zone.name || zone.Name || 'Zone';
ctx.fillStyle = '#ffffff';
ctx.font = '14px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(zoneName, centerX, centerY);
if (count > 0) {
ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.fillText(`(${count})`, centerX, centerY + 16);
}
});
}
function drawPortals(ctx, bounds, colors) {
if (!currentState.portals || currentState.portals.length === 0) {
return;
}
ctx.strokeStyle = '#a855f7'; // Purple
ctx.lineWidth = 0.5;
currentState.portals.forEach(portal => {
// Portal is defined by two points
const p1 = worldToScreen(portal.p1_x || 0, portal.p1_y || 0, bounds);
const p2 = worldToScreen(portal.p2_x || 0, portal.p2_y || 0, bounds);
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
});
}
function drawNodes(ctx, bounds, colors) {
currentState.nodes.forEach(node => {
const x = node.pos_x || node.PosX || 0;
const y = node.pos_y || node.PosY || 0;
const pos = worldToScreen(x, y, bounds);
// Small filled circle (4px radius)
ctx.fillStyle = '#6b7280'; // Grey
ctx.beginPath();
ctx.arc(pos.x, pos.y, 4, 0, Math.PI * 2);
ctx.fill();
});
}
function drawPeople(ctx, bounds, colors) {
currentState.blobs.forEach(blob => {
// Get current position (with lerp interpolation)
let pos = currentPositions.get(blob.id);
if (!pos) {
pos = { x: blob.x, y: blob.y, z: blob.z || 0 };
currentPositions.set(blob.id, pos);
}
// Get target position
const target = targetPositions.get(blob.id);
if (target) {
// Lerp toward target (20% of remaining distance)
pos.x = lerp(pos.x, target.x, LERP_FACTOR);
pos.y = lerp(pos.y, target.y, LERP_FACTOR);
pos.z = lerp(pos.z, target.z, LERP_FACTOR);
// Update the currentPositions map with the lerped position
currentPositions.set(blob.id, { ...pos });
}
const screenPos = worldToScreen(pos.x, pos.y, bounds);
// Blob radius proportional to identity confidence
const confidence = blob.confidence || 0.5;
const radius = 10 + (confidence * 8); // 10-18px
// Get person color
let blobColor = '#6b7280'; // Grey for unknown
if (blob.person) {
blobColor = getPersonColor(blob.person);
}
// Draw person blob
ctx.fillStyle = blobColor;
ctx.beginPath();
ctx.arc(screenPos.x, screenPos.y, radius, 0, Math.PI * 2);
ctx.fill();
// Draw name label above
const name = blob.person ? getFirstName(blob.person) : '?';
ctx.fillStyle = '#ffffff';
ctx.font = '12px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(name, screenPos.x, screenPos.y - radius - 4);
});
}
function drawSystemStatus(ctx, colors) {
const size = 16; // 8px radius = 16px diameter
const margin = 16;
const x = margin + size / 2;
const y = margin + size / 2;
// Determine status color
let statusColor;
if (currentState.alerts.length > 0) {
statusColor = '#ef4444'; // Red - alert
} else {
// Check node health
const onlineNodes = currentState.nodes.filter(n => n.status === 'online').length;
const totalNodes = currentState.nodes.length;
if (onlineNodes === 0 && totalNodes > 0) {
statusColor = '#ef4444'; // Red - all offline
} else if (onlineNodes < totalNodes) {
statusColor = '#f59e0b'; // Amber - some degraded
} else {
statusColor = '#22c55e'; // Green - all healthy
}
}
// Draw status dot
ctx.fillStyle = statusColor;
ctx.beginPath();
ctx.arc(x, y, 8, 0, Math.PI * 2);
ctx.fill();
}
function drawTimeDisplay(ctx, canvasWidth, colors) {
const now = new Date();
const timeStr = now.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
ctx.fillStyle = colors.text;
ctx.font = '28px -apple-system, BlinkMacSystemFont, monospace';
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
ctx.fillText(timeStr, canvasWidth - 16, 16);
}
// ============================================
// Auto-Dim Logic
// ============================================
function resetDimTimer() {
if (dimTimer) {
clearTimeout(dimTimer);
}
dimTimer = setTimeout(() => {
enterDimMode();
}, AUTO_DIM_TIMEOUT_MS);
}
function enterDimMode() {
isDimmed = true;
// Reduce canvas brightness to 40%
canvas.style.filter = 'brightness(0.4)';
canvas.style.transition = 'filter 0.5s ease';
console.log('[AmbientRenderer] Entered dim mode');
}
function checkAmbientZonePresence() {
if (!config.ambientZone) {
return; // No ambient zone configured
}
// Check if anyone is in the ambient zone
const zone = currentState.zones.find(z => z.id === config.ambientZone || z.name === config.ambientZone);
if (zone && (zone.count > 0 || zone.occupancy > 0)) {
// Someone is present - wake from dim
if (isDimmed) {
AmbientRenderer.wakeFromDim();
}
// Reset the timer
resetDimTimer();
}
}
// ============================================
// Alert Pulse Animation
// ============================================
function startAlertPulse() {
if (alertPulseTimer) {
return; // Already running
}
alertPulseTimer = setInterval(() => {
alertPulseState = !alertPulseState;
// Force immediate render
renderFrame();
}, ALERT_PULSE_INTERVAL_MS);
}
function stopAlertPulse() {
if (alertPulseTimer) {
clearInterval(alertPulseTimer);
alertPulseTimer = null;
}
alertPulseState = false;
}
// ============================================
// Helper Functions
// ============================================
function lerp(start, end, factor) {
return start + (end - start) * factor;
}
function getPersonColor(personName) {
// Generate consistent color from name
let hash = 0;
for (let i = 0; i < personName.length; i++) {
hash = personName.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 70%, 50%)`;
}
function getFirstName(fullName) {
if (!fullName) return '?';
const parts = fullName.trim().split(/\s+/);
return parts[0];
}
function updateSystemHealth() {
if (currentState.alerts.length > 0) {
currentState.systemHealth = 'alert';
return;
}
const onlineNodes = currentState.nodes.filter(n => n.status === 'online').length;
const totalNodes = currentState.nodes.length;
if (totalNodes === 0) {
currentState.systemHealth = 'unknown';
} else if (onlineNodes === 0) {
currentState.systemHealth = 'offline';
} else if (onlineNodes < totalNodes) {
currentState.systemHealth = 'degraded';
} else {
currentState.systemHealth = 'healthy';
}
}
// ============================================
// Canvas Interaction
// ============================================
function setupCanvasInteraction() {
if (!canvas) return;
// Handle clicks on canvas (for alert acknowledgment)
canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Check if click is on acknowledge button in alert mode
if (currentState.alerts.length > 0) {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
const buttonWidth = 200;
const buttonHeight = 60;
const buttonX = (width - buttonWidth) / 2;
const buttonY = height / 2 + 80;
if (x >= buttonX && x <= buttonX + buttonWidth &&
y >= buttonY && y <= buttonY + buttonHeight) {
if (onAlertClick) {
onAlertClick(currentState.alerts[0]);
}
}
}
// Any click wakes from dim and resets timer
wakeFromDim();
if (onUserActivity) {
onUserActivity();
}
});
// Handle touch events
canvas.addEventListener('touchstart', (e) => {
wakeFromDim();
if (onUserActivity) {
onUserActivity();
}
}, { passive: true });
}
// ============================================
// Export
// ============================================
window.SpaxelAmbientRenderer = AmbientRenderer;
console.log('[AmbientRenderer] Module loaded');
})();