spaxel/dashboard/js/ambient.js
jedarden 6d30c63414 feat: implement ambient dashboard mode for wall-mounted tablets
- Add /ambient route serving dedicated ambient.html page
- Simplified top-down floor plan using Canvas 2D (no Three.js)
- Time-of-day aware palettes: morning (bright/cool), day (neutral), evening (warm amber), night (dim)
- People rendered as glowing colored dots (BLE-identified) or neutral dots (unknown)
- Room labels with occupancy counts
- Auto-dim after 30 minutes of inactivity
- Alert mode with pulsing red border and action buttons
- Morning briefing integration with auto-dismiss
- WebSocket for real-time blob and zone updates
- Lightweight implementation targeting <30 MB RAM for older tablets
- OLED-safe night mode with true black background
2026-04-09 22:26:37 -04:00

1148 lines
34 KiB
JavaScript

/**
* Spaxel Dashboard - Ambient Mode
*
* Simplified, always-on display mode for wall-mounted tablets.
* Time-of-day aware palettes, auto-dim, and calm visualization.
*/
(function() {
'use strict';
// ============================================
// Configuration
// ============================================
const TIME_PERIODS = {
morning: { start: 6, end: 10 }, // 6am - 10am
day: { start: 10, end: 18 }, // 10am - 6pm
evening: { start: 18, end: 22 }, // 6pm - 10pm
night: { start: 22, end: 6 } // 10pm - 6am
};
const UPDATE_INTERVAL = 5000; // 5 seconds
const DIM_TIMEOUT = 30 * 60 * 1000; // 30 minutes of inactivity
const BRIEFING_DURATION = 30 * 1000; // 30 seconds
// ============================================
// State
// ============================================
let isActive = false;
let canvas = null;
let ctx = null;
let currentState = {
zones: [],
blobs: [],
alerts: [],
securityMode: false,
nodesOnline: 0,
nodesTotal: 0,
lastUpdate: null
};
let ws = null;
let wsReconnectTimer = null;
let updateTimer = null;
let dimTimer = null;
let briefingTimer = null;
let isDimmed = false;
// ============================================
// Initialization
// ============================================
/**
* Initialize ambient mode
*/
function init() {
console.log('[Ambient Mode] Initializing...');
// Check if we should be in ambient mode
checkAmbientMode();
// Set up time-of-day updates
startTimeOfDayUpdater();
// Set up activity monitoring
startActivityMonitoring();
console.log('[Ambient Mode] Initialized');
}
/**
* Check if ambient mode should be active
*/
function checkAmbientMode() {
// For standalone ambient.html, always enable
// For main dashboard, check hash
if (window.location.pathname.endsWith('/ambient.html') ||
window.location.pathname === '/ambient') {
enableAmbientMode();
return;
}
const hash = window.location.hash.slice(1);
if (hash === 'ambient') {
enableAmbientMode();
} else if (isActive) {
disableAmbientMode();
}
}
/**
* Enable ambient mode
*/
function enableAmbientMode() {
if (isActive) return;
isActive = true;
document.body.classList.add('ambient-mode');
// Create ambient UI
createAmbientUI();
// Set initial time period
updateTimeOfDay();
// Connect WebSocket
connectWebSocket();
// Show briefing if this is first detection today
checkAndShowBriefing();
console.log('[Ambient Mode] Enabled');
}
/**
* Disable ambient mode
*/
function disableAmbientMode() {
if (!isActive) return;
isActive = false;
document.body.classList.remove('ambient-mode');
// Disconnect WebSocket
disconnectWebSocket();
// Remove ambient UI
const ambientContainer = document.getElementById('ambient-container');
if (ambientContainer) {
ambientContainer.remove();
}
// Stop timers
stopUpdates();
console.log('[Ambient Mode] Disabled');
}
// ============================================
// UI Creation
// ============================================
/**
* Create ambient mode UI
*/
function createAmbientUI() {
// Check if already exists
if (document.getElementById('ambient-container')) {
return;
}
const container = document.createElement('div');
container.id = 'ambient-container';
container.innerHTML = `
<div class="ambient-floorplan">
<canvas id="ambient-canvas" class="ambient-canvas"></canvas>
</div>
<div class="ambient-status">
<div class="ambient-status-item">
<div class="ambient-status-dot" id="ambient-status-dot"></div>
<span id="ambient-status-text">Loading...</span>
</div>
<div class="ambient-status-item">
<span id="ambient-time"></span>
</div>
<div class="ambient-status-item">
<span id="ambient-nodes">0 nodes</span>
</div>
</div>
<!-- Alert overlay -->
<div id="ambient-alert" class="ambient-alert hidden">
<div class="ambient-alert-icon">&#x26A0;</div>
<div class="ambient-alert-title" id="alert-title">Alert</div>
<div class="ambient-alert-message" id="alert-message"></div>
<div class="ambient-alert-actions">
<button class="ambient-alert-btn primary" id="alert-action-primary">I'm Fine</button>
<button class="ambient-alert-btn secondary" id="alert-action-secondary">Dismiss</button>
</div>
</div>
<!-- Morning briefing overlay -->
<div id="ambient-briefing" class="ambient-briefing hidden">
<div class="ambient-briefing-content">
<div class="ambient-briefing-greeting" id="briefing-greeting">Good morning!</div>
<div id="briefing-content"></div>
<button class="ambient-briefing-dismiss" id="briefing-dismiss">Got it</button>
</div>
</div>
<!-- "All Secure" message -->
<div id="ambient-secure" class="ambient-secure" style="display: none;">
<div class="ambient-secure-icon">&#x1F512;</div>
<div class="ambient-secure-text">All secure</div>
</div>
`;
document.body.appendChild(container);
// Set up canvas
canvas = document.getElementById('ambient-canvas');
ctx = canvas.getContext('2d');
// Handle resize
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
// Set up event listeners
setupEventListeners();
}
/**
* Set up event listeners
*/
function setupEventListeners() {
// Alert action buttons
document.getElementById('alert-action-primary')?.addEventListener('click', handleAlertAction);
document.getElementById('alert-action-secondary')?.addEventListener('click', dismissAlert);
// Briefing dismiss
document.getElementById('briefing-dismiss')?.addEventListener('click', dismissBriefing);
// Touch/click to wake from dim
document.getElementById('ambient-container')?.addEventListener('click', wakeFromDim);
// Monitor for route changes
window.addEventListener('hashchange', checkAmbientMode);
}
/**
* Resize canvas to fit container
*/
function resizeCanvas() {
if (!canvas || !ctx) return;
const container = document.querySelector('.ambient-floorplan');
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';
ctx.scale(dpr, dpr);
// Re-render
render();
}
// ============================================
// Time of Day
// ============================================
/**
* Start time-of-day updater
*/
function startTimeOfDayUpdater() {
updateTimeOfDay();
setInterval(updateTimeOfDay, 60000); // Check every minute
}
/**
* Update time-of-day theme
*/
function updateTimeOfDay() {
if (!isActive) return;
const hour = new Date().getHours();
let period = 'night';
for (const [key, range] of Object.entries(TIME_PERIODS)) {
if (range.start <= range.end) {
// Normal period (e.g., morning: 6-10)
if (hour >= range.start && hour < range.end) {
period = key;
break;
}
} else {
// Overnight period (e.g., night: 22-6)
if (hour >= range.start || hour < range.end) {
period = key;
break;
}
}
}
// Remove all time periods
document.body.classList.remove('time-morning', 'time-day', 'time-evening', 'time-night');
// Add current period
document.body.classList.add('time-' + period);
console.log('[Ambient Mode] Time period:', period);
}
// ============================================
// WebSocket Connection
// ============================================
/**
* Connect to WebSocket for real-time updates
*/
function connectWebSocket() {
// Disconnect existing connection
if (ws) {
ws.close();
}
// Determine WebSocket protocol
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = window.location.host;
const wsUrl = `${wsProtocol}//${wsHost}/ws/dashboard`;
console.log('[Ambient Mode] Connecting WebSocket:', wsUrl);
ws = new WebSocket(wsUrl);
ws.onopen = function() {
console.log('[Ambient Mode] WebSocket connected');
updateConnectionStatus(true);
// Clear reconnect timer
if (wsReconnectTimer) {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = null;
}
};
ws.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
} catch (e) {
console.error('[Ambient Mode] Error parsing WebSocket message:', e);
}
};
ws.onclose = function(event) {
console.log('[Ambient Mode] WebSocket disconnected:', event.code, event.reason);
updateConnectionStatus(false);
// Attempt to reconnect
if (isActive) {
const delay = 5000; // 5 seconds
wsReconnectTimer = setTimeout(connectWebSocket, delay);
}
};
ws.onerror = function(error) {
console.error('[Ambient Mode] WebSocket error:', error);
updateConnectionStatus(false);
};
}
/**
* Disconnect WebSocket
*/
function disconnectWebSocket() {
if (ws) {
ws.close();
ws = null;
}
if (wsReconnectTimer) {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = null;
}
}
/**
* Update connection status indicator
*/
function updateConnectionStatus(connected) {
const statusDot = document.getElementById('ambient-status-dot');
if (statusDot) {
if (connected) {
statusDot.className = 'ambient-status-dot online';
} else {
statusDot.className = 'ambient-status-dot';
statusDot.style.background = '#ff3b30';
}
}
}
/**
* Handle WebSocket message
*/
function handleWebSocketMessage(data) {
// Handle snapshot message (first message on connect)
if (data.type === 'snapshot' || (!data.type && data.blobs !== undefined)) {
// Full snapshot
if (data.zones) currentState.zones = data.zones;
if (data.blobs) currentState.blobs = data.blobs;
if (data.events) currentState.alerts = data.events.filter(e => e.type === 'alert' || e.type === 'fall_alert' || e.type === 'anomaly');
if (data.security_mode !== undefined) currentState.securityMode = data.security_mode;
currentState.lastUpdate = new Date();
// Update UI
updateStatus();
checkAlerts();
render();
return;
}
// Handle incremental updates
if (data.blobs) {
currentState.blobs = data.blobs;
}
if (data.zones) {
currentState.zones = data.zones;
}
if (data.events && data.events.length > 0) {
// Add new alerts
data.events.forEach(event => {
if (event.type === 'alert' || event.type === 'fall_alert' || event.type === 'anomaly') {
// Check if alert already exists
const exists = currentState.alerts.some(a => a.id === event.id);
if (!exists) {
currentState.alerts.push(event);
}
}
});
}
currentState.lastUpdate = new Date();
// Update UI
updateStatus();
checkAlerts();
render();
}
// ============================================
// Data Updates (polling fallback)
// ============================================
/**
* Start periodic updates (fallback if WebSocket fails)
*/
function startUpdates() {
if (updateTimer) {
clearInterval(updateTimer);
}
// Note: Updates primarily come via WebSocket
// This timer is just for periodic status refresh
updateTimer = setInterval(() => {
updateStatus();
}, 10000); // Every 10 seconds
}
/**
* Stop updates
*/
function stopUpdates() {
if (updateTimer) {
clearInterval(updateTimer);
updateTimer = null;
}
}
/**
* Fetch data for ambient display (fallback polling)
*/
async function fetchAmbientData() {
if (!isActive) return;
try {
// Fetch zones
const zonesResponse = await fetch('/api/zones');
if (zonesResponse.ok) {
currentState.zones = await zonesResponse.json();
}
// Fetch blobs
const blobsResponse = await fetch('/api/blobs');
if (blobsResponse.ok) {
currentState.blobs = await blobsResponse.json();
}
// Fetch system status
const statusResponse = await fetch('/api/status');
if (statusResponse.ok) {
const statusData = await statusResponse.json();
currentState.securityMode = statusData.security_mode || false;
currentState.nodesOnline = statusData.nodes_online || 0;
currentState.nodesTotal = statusData.nodes || 0;
}
// Fetch recent alerts
const alertsResponse = await fetch('/api/events?limit=5&type=alert');
if (alertsResponse.ok) {
const alertsData = await alertsResponse.json();
currentState.alerts = alertsData.events || [];
}
currentState.lastUpdate = new Date();
// Update UI
updateStatus();
checkAlerts();
render();
} catch (error) {
console.error('[Ambient Mode] Error fetching data:', error);
}
}
/**
* Update status bar
*/
function updateStatus() {
const statusDot = document.getElementById('ambient-status-dot');
const statusText = document.getElementById('ambient-status-text');
const timeDisplay = document.getElementById('ambient-time');
const nodesDisplay = document.getElementById('ambient-nodes');
if (statusDot && statusText) {
// Determine status based on alerts and security mode
if (currentState.alerts.length > 0) {
statusDot.className = 'ambient-status-dot alert';
statusText.textContent = 'Alert active';
} else if (currentState.securityMode) {
statusDot.className = 'ambient-status-dot';
statusDot.style.background = '#ff9500';
statusText.textContent = 'Security armed';
} else {
statusDot.className = 'ambient-status-dot online';
statusText.textContent = 'All secure';
}
}
if (timeDisplay) {
const now = new Date();
timeDisplay.textContent = now.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
if (nodesDisplay) {
nodesDisplay.textContent = `${currentState.nodesOnline}/${currentState.nodesTotal} nodes`;
}
}
/**
* Check for alerts and show overlay
*/
function checkAlerts() {
const alertOverlay = document.getElementById('ambient-alert');
const secureMessage = document.getElementById('ambient-secure');
if (!alertOverlay) return;
if (currentState.alerts.length > 0) {
// Show alert
const latestAlert = currentState.alerts[0];
showAlert(latestAlert);
secureMessage.style.display = 'none';
} else {
// Hide alert, show secure if no people
alertOverlay.classList.add('hidden');
const hasPeople = currentState.blobs.length > 0;
if (!hasPeople && !isDimmed) {
secureMessage.style.display = 'block';
} else {
secureMessage.style.display = 'none';
}
}
}
/**
* Show alert overlay
*/
function showAlert(alert) {
const alertOverlay = document.getElementById('ambient-alert');
const titleEl = document.getElementById('alert-title');
const messageEl = document.getElementById('alert-message');
if (!alertOverlay) return;
titleEl.textContent = alert.title || 'Alert';
messageEl.textContent = formatAlertMessage(alert);
alertOverlay.classList.remove('hidden');
document.getElementById('ambient-container').classList.add('alert-active');
// Wake from dim
wakeFromDim();
}
/**
* Format alert message
*/
function formatAlertMessage(alert) {
if (alert.detail_json) {
try {
const detail = typeof alert.detail_json === 'string'
? JSON.parse(alert.detail_json)
: alert.detail_json;
return detail.message || detail.description || 'Alert triggered';
} catch (e) {
// Ignore parse errors
}
}
return 'Alert detected in your home';
}
/**
* Handle alert action button
*/
function handleAlertAction() {
// Dismiss the alert and mark as handled
dismissAlert();
// In a real implementation, this would call an API to acknowledge the alert
showToast('Alert acknowledged', 'info');
}
/**
* Dismiss alert overlay
*/
function dismissAlert() {
const alertOverlay = document.getElementById('ambient-alert');
if (alertOverlay) {
alertOverlay.classList.add('hidden');
document.getElementById('ambient-container').classList.remove('alert-active');
}
}
// ============================================
// Rendering
// ============================================
/**
* Render the ambient display
*/
function render() {
if (!canvas || !ctx) return;
const width = canvas.width / (window.devicePixelRatio || 1);
const height = canvas.height / (window.devicePixelRatio || 1);
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Get floor plan bounds (default to centered square)
const bounds = getFloorPlanBounds(width, height);
// Draw floor plan background
drawFloorPlan(ctx, bounds);
// Draw zones
drawZones(ctx, bounds);
// Draw people
drawPeople(ctx, bounds);
}
/**
* Get floor plan bounds
*/
function getFloorPlanBounds(canvasWidth, canvasHeight) {
// Default bounds - centered square with margin
const margin = 40;
const size = Math.min(canvasWidth, canvasHeight) - margin * 2;
return {
x: (canvasWidth - size) / 2,
y: (canvasHeight - size) / 2,
width: size,
height: size
};
}
/**
* Draw floor plan background
*/
function drawFloorPlan(ctx, bounds) {
// Draw floor rectangle
ctx.fillStyle = '#f5f5f7';
ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
// Draw grid lines
ctx.strokeStyle = '#e0e0e0';
ctx.lineWidth = 1;
const gridSize = 50;
for (let x = bounds.x; x <= bounds.x + bounds.width; x += gridSize) {
ctx.beginPath();
ctx.moveTo(x, bounds.y);
ctx.lineTo(x, bounds.y + bounds.height);
ctx.stroke();
}
for (let y = bounds.y; y <= bounds.y + bounds.height; y += gridSize) {
ctx.beginPath();
ctx.moveTo(bounds.x, y);
ctx.lineTo(bounds.x + bounds.width, y);
ctx.stroke();
}
// Draw border
ctx.strokeStyle = '#ccc';
ctx.lineWidth = 2;
ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
}
/**
* Draw zones
*/
function drawZones(ctx, bounds) {
if (!currentState.zones || currentState.zones.length === 0) {
return;
}
// Find the bounds of all zones (using x and y for 2D floor plan)
const allZones = currentState.zones;
// Zones have x, y, z (3D position) and w, d, h (size)
// For 2D top-down view, we use x (horizontal) and y (depth)
// Note: Zone JSON uses field names: x, y, z for position and w, d, h for sizes
// But in the actual ZoneSnapshot, these map to: MinX, MinY, MinZ and SizeX, SizeY, SizeZ
// For 2D rendering: x = MinX, y = MinY, width = SizeX, height = SizeY (depth)
const minX = Math.min(...allZones.map(z => z.x || z.MinX || 0));
const minY = Math.min(...allZones.map(z => z.y || z.MinY || 0));
const maxX = Math.max(...allZones.map(z => (z.x || z.MinX || 0) + (z.w || z.SizeX || z.w || 0)));
const maxY = Math.max(...allZones.map(z => (z.y || z.MinY || 0) + (z.d || z.SizeY || z.d || 0)));
const zoneScale = Math.min(
bounds.width / (maxX - minX || 1),
bounds.height / (maxY - minY || 1)
);
// Draw each zone
allZones.forEach(zone => {
const zoneX = zone.x || zone.MinX || 0;
const zoneY = zone.y || zone.MinY || 0;
const zoneW = zone.w || zone.SizeX || zone.w || 1;
const zoneD = zone.d || zone.SizeY || zone.d || 1;
const zx = bounds.x + (zoneX - minX) * zoneScale;
const zy = bounds.y + (zoneY - minY) * zoneScale;
const zw = zoneW * zoneScale;
const zh = zoneD * zoneScale;
// Zone background
const count = zone.count || zone.occupancy || zone.Count || 0;
const isOccupied = count > 0;
ctx.fillStyle = isOccupied ? 'rgba(52, 199, 89, 0.15)' : 'rgba(200, 200, 200, 0.1)';
ctx.fillRect(zx, zy, zw, zh);
// Zone border
ctx.strokeStyle = isOccupied ? 'rgba(52, 199, 89, 0.3)' : 'rgba(200, 200, 200, 0.3)';
ctx.lineWidth = 2;
ctx.strokeRect(zx, zy, zw, zh);
// Zone label
drawZoneLabel(ctx, zone, zx, zy, zw, count);
});
}
/**
* Draw zone label
*/
function drawZoneLabel(ctx, zone, x, y, width, count) {
const zoneName = zone.name || zone.Name || 'Zone';
const labelText = `${zoneName}${count > 0 ? ` (${count})` : ''}`;
ctx.font = '13px -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Background for label
const metrics = ctx.measureText(labelText);
const padding = 8;
const labelWidth = metrics.width + padding * 2;
const labelHeight = 24;
const labelX = x + width / 2 - labelWidth / 2;
const labelY = y - labelHeight / 2;
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
ctx.beginPath();
ctx.roundRect(labelX, labelY, labelWidth, labelHeight, 6);
ctx.fill();
// Text
ctx.fillStyle = count > 0 ? '#333' : '#666';
ctx.fillText(labelText, x + width / 2, y + labelHeight / 2);
}
/**
* Draw people
*/
function drawPeople(ctx, bounds) {
if (!currentState.blobs || currentState.blobs.length === 0) {
return;
}
// Find the bounds of all blobs
const minX = Math.min(...currentState.blobs.map(b => b.x));
const minY = Math.min(...currentState.blobs.map(b => b.y));
const maxX = Math.max(...currentState.blobs.map(b => b.x));
const maxY = Math.max(...currentState.blobs.map(b => b.y));
const blobScale = Math.min(
bounds.width / (maxX - minX || 1),
bounds.height / (maxY - minY || 1)
);
// Assign person indices for consistent coloring
const personIndices = new Map();
let nextIndex = 0;
currentState.blobs.forEach((blob, index) => {
const bx = bounds.x + (blob.x - minX) * blobScale;
const by = bounds.y + (blob.y - minY) * blobScale;
// Get person index
let personIndex = personIndices.get(blob.person);
if (personIndex === undefined) {
personIndex = nextIndex++;
personIndices.set(blob.person, personIndex);
}
// Draw person circle
drawPerson(ctx, bx, by, blob.person, personIndex);
});
}
/**
* Draw a person indicator
*/
function drawPerson(ctx, x, y, person, index) {
const radius = 20;
// Person circle
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
if (person) {
// Known person - use their color
const color = getPersonColor(person);
ctx.fillStyle = color;
} else {
// Unknown person - use index for color
ctx.fillStyle = '#95a5a6';
}
ctx.fill();
// Add glow effect
ctx.shadowColor = ctx.fillStyle;
ctx.shadowBlur = 10;
ctx.fill();
ctx.shadowBlur = 0;
// Person initial or icon
ctx.fillStyle = 'white';
ctx.font = 'bold 14px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (person) {
const initials = getPersonInitials(person);
ctx.fillText(initials, x, y);
} else {
ctx.fillText('?', x, y);
}
// Position indicator (pillar)
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.fillRect(x - 2, y, 4, radius + 8);
}
/**
* Get person color
*/
function getPersonColor(person) {
// Generate consistent color from name
let hash = 0;
for (let i = 0; i < person.length; i++) {
hash = person.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 70%, 50%)`;
}
/**
* Get person initials
*/
function getPersonInitials(person) {
const parts = person.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return person.substring(0, 2).toUpperCase();
}
// ============================================
// Activity Monitoring & Dimming
// ============================================
/**
* Start activity monitoring
*/
function startActivityMonitoring() {
// Reset dim timer on any user interaction
const events = ['click', 'touchstart', 'keydown', 'mousemove'];
events.forEach(event => {
document.addEventListener(event, resetDimTimer, { passive: true });
});
// Start dim timer
resetDimTimer();
}
/**
* Reset the dim timer
*/
function resetDimTimer() {
if (!isActive) return;
// Clear existing timer
if (dimTimer) {
clearTimeout(dimTimer);
}
// Wake up if dimmed
if (isDimmed) {
wakeFromDim();
}
// Set new timer
dimTimer = setTimeout(() => {
if (!isAlertActive()) {
enterDimMode();
}
}, DIM_TIMEOUT);
}
/**
* Check if alert is active
*/
function isAlertActive() {
const alertOverlay = document.getElementById('ambient-alert');
return alertOverlay && !alertOverlay.classList.contains('hidden');
}
/**
* Enter dim mode
*/
function enterDimMode() {
if (isDimmed) return;
isDimmed = true;
document.getElementById('ambient-container')?.classList.add('dimmed');
// Hide "All Secure" message
document.getElementById('ambient-secure').style.display = 'none';
console.log('[Ambient Mode] Entered dim mode');
}
/**
* Wake from dim mode
*/
function wakeFromDim() {
if (!isDimmed) return;
isDimmed = false;
document.getElementById('ambient-container')?.classList.remove('dimmed');
console.log('[Ambient Mode] Woke from dim mode');
}
// ============================================
// Morning Briefing
// ============================================
/**
* Check and show morning briefing
*/
async function checkAndShowBriefing() {
// Check if briefing was already shown today
const today = new Date().toISOString().split('T')[0];
const lastShown = localStorage.getItem('ambient_briefing_last_shown');
if (lastShown === today) {
return; // Already shown today
}
// Check if this is morning and first detection
const hour = new Date().getHours();
if (hour < 6 || hour >= 12) {
return; // Not morning hours
}
// Fetch briefing
try {
const response = await fetch(`/api/briefings/${today}`);
if (response.ok) {
const briefing = await response.json();
// Show briefing
showBriefing(briefing);
// Mark as shown
localStorage.setItem('ambient_briefing_last_shown', today);
}
} catch (error) {
console.error('[Ambient Mode] Error fetching briefing:', error);
}
}
/**
* Show morning briefing
*/
function showBriefing(briefing) {
const briefingEl = document.getElementById('ambient-briefing');
const greetingEl = document.getElementById('briefing-greeting');
const contentEl = document.getElementById('briefing-content');
if (!briefingEl) return;
greetingEl.textContent = getGreeting();
// Parse and display content
contentEl.innerHTML = parseBriefingContent(briefing.content);
briefingEl.classList.remove('hidden');
// Auto-dismiss after duration
briefingTimer = setTimeout(() => {
dismissBriefing();
}, BRIEFING_DURATION);
// Wake from dim
wakeFromDim();
}
/**
* Dismiss morning briefing
*/
function dismissBriefing() {
const briefingEl = document.getElementById('ambient-briefing');
if (briefingEl) {
briefingEl.classList.add('hidden');
}
if (briefingTimer) {
clearTimeout(briefingTimer);
briefingTimer = null;
}
}
/**
* Get greeting based on time of day
*/
function getGreeting() {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 17) return 'Good afternoon';
return 'Good evening';
}
/**
* Parse briefing content for display
*/
function parseBriefingContent(content) {
// Convert plain text to HTML
const lines = content.split('\n');
return lines.map(line => {
if (line.trim() === '') {
return '<br>';
}
return `<div class="ambient-briefing-section-value">${line}</div>`;
}).join('');
}
// ============================================
// Helper Functions
// ============================================
/**
* Show toast notification
*/
function showToast(message, type = 'info') {
if (window.showToast) {
window.showToast(message, type);
return;
}
// Fallback toast
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 12px 20px;
border-radius: 8px;
z-index: 200;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'fadeOut 0.3s ease-out forwards';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// ============================================
// Public API
// ============================================
window.SpaxelAmbientMode = {
init: init,
enable: enableAmbientMode,
disable: disableAmbientMode,
isActive: () => isActive,
refresh: fetchAmbientData
};
// Auto-initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Monitor hash changes
window.addEventListener('hashchange', checkAmbientMode);
console.log('[Ambient Mode] Module loaded');
})();