- 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>
835 lines
26 KiB
JavaScript
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');
|
|
})();
|