mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
feat(ui): UI overhaul — consolidates #305-#309 (keyboard shortcuts, perf monitor, toasts, theme, command palette, activity log, data export, mobile PWA, accessibility, i18n) (#620)
* feat(ui): add keyboard shortcuts, perf monitor, toast system, theme toggle, and WCAG accessibility - Keyboard shortcuts overlay (press ? for help, 1-8 for tabs, T for theme, P for perf) - Real-time performance monitor with FPS, memory, latency sparklines (draggable) - Enhanced toast notification system with stacking, auto-dismiss, progress bars - Dark/light theme toggle with localStorage persistence and system preference detection - WCAG accessibility: skip-to-content link, ARIA roles/attributes on tabs and panels, arrow key navigation in tab bar, focus-visible outlines - ESLint config for UI directory with security and quality rules * feat(ui): add command palette, activity log, data export, fullscreen mode, connection status - Command palette (Ctrl+K / Cmd+K) with fuzzy search across tabs and actions - Activity log panel (L key) with real-time console interception, filters, resizable - Data export utility (E key) for sensor data as JSON/CSV with dialog - Fullscreen mode (F key / F11) for visualization tabs with exit button - Connection status widget in header showing WebSocket state and reconnect * feat(ui): add mobile hamburger nav, PWA support, and 40 unit tests - Mobile hamburger navigation: slide-out drawer replacing tab bar on <768px, swipe-to-close, animated hamburger icon, auto-sync with tab manager - PWA manifest + service worker: installable dashboard, offline shell caching (cache-first for static, network-first for API), auto-cleanup of old caches - 40 unit tests for ToastManager, ThemeToggle, KeyboardShortcuts, PerfMonitor, TabManager - browser-based test runner at ui/tests/unit-tests.html - PWA meta tags: theme-color, apple-mobile-web-app-capable, manifest link - Icon generator page for creating PWA icons (ui/icons/generate.html) * feat(ui): add URL routing, onboarding tour, idle detection, notification center - Hash router: tabs are bookmarkable/shareable via URL (#demo, #sensing, etc.), syncs with TabManager, supports browser back/forward navigation - Onboarding tour: interactive 6-step first-run walkthrough with spotlight highlighting, step indicators, skip/back/next controls, localStorage persistence - Idle detection: pauses health polling and reduces CSS animations after 3 min of inactivity, resumes on user interaction, integrates with Page Visibility API - Notification center: bell icon in header with unread badge, event history panel with mark-read/clear, persists across page views via sessionStorage * feat(ui): add i18n (EN/PL), screenshot tool, settings panel, reduced motion, uptime clock - i18n: English/Polish translations with auto-detection, language selector in header, data-i18n attributes on dashboard elements, localStorage persistence - Screenshot tool (S key): captures active tab to clipboard or downloads PNG, flash effect, canvas rendering with watermark, fallback for tainted canvases - Quick settings panel (gear icon): reduced motion toggle, high contrast mode, compact layout mode, health polling toggle, clear data, reset onboarding - Uptime clock: current time + session duration in header - prefers-reduced-motion: system-level and manual toggle, disables all animations and transitions for vestibular accessibility - High contrast mode: WCAG AAA compliant colors for both light and dark themes - Compact mode: condensed layout for dense information display
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2022": true
|
||||||
|
},
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2022,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||||
|
"no-undef": "error",
|
||||||
|
"no-var": "error",
|
||||||
|
"prefer-const": "warn",
|
||||||
|
"eqeqeq": ["error", "always"],
|
||||||
|
"no-eval": "error",
|
||||||
|
"no-implied-eval": "error",
|
||||||
|
"no-new-func": "error",
|
||||||
|
"no-script-url": "error",
|
||||||
|
"no-alert": "warn",
|
||||||
|
"no-console": ["warn", { "allow": ["warn", "error", "info"] }],
|
||||||
|
"curly": ["warn", "multi-line"],
|
||||||
|
"no-throw-literal": "error",
|
||||||
|
"prefer-template": "warn",
|
||||||
|
"no-duplicate-imports": "error"
|
||||||
|
},
|
||||||
|
"ignorePatterns": [
|
||||||
|
"node_modules/",
|
||||||
|
"mobile/",
|
||||||
|
"vendor/",
|
||||||
|
"*.min.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -10,6 +10,24 @@ import { wsService } from './services/websocket.service.js';
|
|||||||
import { healthService } from './services/health.service.js';
|
import { healthService } from './services/health.service.js';
|
||||||
import { sensingService } from './services/sensing.service.js';
|
import { sensingService } from './services/sensing.service.js';
|
||||||
import { backendDetector } from './utils/backend-detector.js';
|
import { backendDetector } from './utils/backend-detector.js';
|
||||||
|
import { KeyboardShortcuts } from './utils/keyboard-shortcuts.js';
|
||||||
|
import { PerfMonitor } from './utils/perf-monitor.js';
|
||||||
|
import { toastManager } from './utils/toast.js';
|
||||||
|
import { ThemeToggle } from './utils/theme-toggle.js';
|
||||||
|
import { CommandPalette } from './utils/command-palette.js';
|
||||||
|
import { ActivityLog } from './utils/activity-log.js';
|
||||||
|
import { DataExport } from './utils/data-export.js';
|
||||||
|
import { FullscreenManager } from './utils/fullscreen.js';
|
||||||
|
import { ConnectionStatus } from './utils/connection-status.js';
|
||||||
|
import { MobileNav } from './utils/mobile-nav.js';
|
||||||
|
import { Router } from './utils/router.js';
|
||||||
|
import { Onboarding } from './utils/onboarding.js';
|
||||||
|
import { IdleManager } from './utils/idle-manager.js';
|
||||||
|
import { NotificationCenter } from './utils/notification-center.js';
|
||||||
|
import { i18n } from './utils/i18n.js';
|
||||||
|
import { ScreenshotTool } from './utils/screenshot.js';
|
||||||
|
import { UptimeClock } from './utils/uptime-clock.js';
|
||||||
|
import { QuickSettings } from './utils/quick-settings.js';
|
||||||
|
|
||||||
class WiFiDensePoseApp {
|
class WiFiDensePoseApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -30,10 +48,13 @@ class WiFiDensePoseApp {
|
|||||||
|
|
||||||
// Initialize UI components
|
// Initialize UI components
|
||||||
this.initializeComponents();
|
this.initializeComponents();
|
||||||
|
|
||||||
|
// Initialize enhancements
|
||||||
|
this.initializeEnhancements();
|
||||||
|
|
||||||
// Set up global event listeners
|
// Set up global event listeners
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
|
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
console.log('WiFi DensePose UI initialized successfully');
|
console.log('WiFi DensePose UI initialized successfully');
|
||||||
|
|
||||||
@@ -167,6 +188,118 @@ class WiFiDensePoseApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize enhancement modules
|
||||||
|
initializeEnhancements() {
|
||||||
|
// Toast notifications
|
||||||
|
toastManager.init();
|
||||||
|
|
||||||
|
// Connection status widget in header
|
||||||
|
this.connectionStatus = new ConnectionStatus();
|
||||||
|
this.connectionStatus.init();
|
||||||
|
|
||||||
|
// Theme toggle
|
||||||
|
this.themeToggle = new ThemeToggle();
|
||||||
|
this.themeToggle.init();
|
||||||
|
|
||||||
|
// Performance monitor
|
||||||
|
this.perfMonitor = new PerfMonitor();
|
||||||
|
this.perfMonitor.init();
|
||||||
|
|
||||||
|
// Activity log
|
||||||
|
this.activityLog = new ActivityLog();
|
||||||
|
this.activityLog.init();
|
||||||
|
|
||||||
|
// Data export
|
||||||
|
this.dataExport = new DataExport();
|
||||||
|
this.dataExport.init();
|
||||||
|
|
||||||
|
// Fullscreen manager
|
||||||
|
this.fullscreenManager = new FullscreenManager();
|
||||||
|
this.fullscreenManager.init();
|
||||||
|
|
||||||
|
// Command palette (Ctrl+K)
|
||||||
|
this.commandPalette = new CommandPalette(this);
|
||||||
|
this.commandPalette.init();
|
||||||
|
|
||||||
|
// Mobile navigation (hamburger menu for small screens)
|
||||||
|
this.mobileNav = new MobileNav();
|
||||||
|
this.mobileNav.init();
|
||||||
|
|
||||||
|
// Notification center (bell icon in header)
|
||||||
|
this.notificationCenter = new NotificationCenter();
|
||||||
|
this.notificationCenter.init();
|
||||||
|
|
||||||
|
// Screenshot tool
|
||||||
|
this.screenshotTool = new ScreenshotTool();
|
||||||
|
this.screenshotTool.init();
|
||||||
|
|
||||||
|
// Uptime clock
|
||||||
|
this.uptimeClock = new UptimeClock();
|
||||||
|
this.uptimeClock.init();
|
||||||
|
|
||||||
|
// Quick settings panel
|
||||||
|
this.quickSettings = new QuickSettings(this);
|
||||||
|
this.quickSettings.init();
|
||||||
|
|
||||||
|
// Internationalization (EN/PL)
|
||||||
|
i18n.init();
|
||||||
|
|
||||||
|
// Keyboard shortcuts (pass app reference for tab switching)
|
||||||
|
this.keyboardShortcuts = new KeyboardShortcuts(this);
|
||||||
|
this.keyboardShortcuts.register('l', 'Toggle activity log', () => {
|
||||||
|
document.dispatchEvent(new CustomEvent('toggle-activity-log'));
|
||||||
|
});
|
||||||
|
this.keyboardShortcuts.register('e', 'Export sensor data', () => {
|
||||||
|
document.dispatchEvent(new CustomEvent('export-data'));
|
||||||
|
});
|
||||||
|
this.keyboardShortcuts.register('f', 'Toggle fullscreen', () => {
|
||||||
|
document.dispatchEvent(new CustomEvent('toggle-fullscreen'));
|
||||||
|
});
|
||||||
|
this.keyboardShortcuts.register('s', 'Take screenshot', () => {
|
||||||
|
document.dispatchEvent(new CustomEvent('take-screenshot'));
|
||||||
|
});
|
||||||
|
this.keyboardShortcuts.init();
|
||||||
|
|
||||||
|
// Listen for show-shortcuts from command palette
|
||||||
|
document.addEventListener('show-shortcuts', () => {
|
||||||
|
this.keyboardShortcuts.showHelp();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register PWA service worker
|
||||||
|
this.registerServiceWorker();
|
||||||
|
|
||||||
|
// URL hash router (bookmarkable tabs)
|
||||||
|
this.router = new Router(this);
|
||||||
|
this.router.init();
|
||||||
|
|
||||||
|
// Idle detection (pause updates when inactive)
|
||||||
|
this.idleManager = new IdleManager();
|
||||||
|
this.idleManager.onIdle(() => {
|
||||||
|
healthService.stopHealthMonitoring();
|
||||||
|
console.info('[App] Paused health monitoring (idle)');
|
||||||
|
});
|
||||||
|
this.idleManager.onActive(() => {
|
||||||
|
healthService.startHealthMonitoring();
|
||||||
|
console.info('[App] Resumed health monitoring (active)');
|
||||||
|
});
|
||||||
|
this.idleManager.init();
|
||||||
|
|
||||||
|
// Onboarding tour (first-run walkthrough)
|
||||||
|
this.onboarding = new Onboarding(this);
|
||||||
|
this.onboarding.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register service worker for offline capability
|
||||||
|
registerServiceWorker() {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('./sw.js').then(reg => {
|
||||||
|
console.info('Service worker registered:', reg.scope);
|
||||||
|
}).catch(err => {
|
||||||
|
console.warn('Service worker registration failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle tab changes
|
// Handle tab changes
|
||||||
handleTabChange(newTab, oldTab) {
|
handleTabChange(newTab, oldTab) {
|
||||||
console.log(`Tab changed from ${oldTab} to ${newTab}`);
|
console.log(`Tab changed from ${oldTab} to ${newTab}`);
|
||||||
@@ -272,45 +405,17 @@ class WiFiDensePoseApp {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show backend status notification
|
// Show backend status notification (uses enhanced toast system)
|
||||||
showBackendStatus(message, type) {
|
showBackendStatus(message, type) {
|
||||||
// Create status notification if it doesn't exist
|
const toastType = type === 'success' ? 'success' : 'warning';
|
||||||
let statusToast = document.getElementById('backendStatusToast');
|
toastManager[toastType](message, {
|
||||||
if (!statusToast) {
|
duration: type === 'success' ? 3000 : 8000
|
||||||
statusToast = document.createElement('div');
|
});
|
||||||
statusToast.id = 'backendStatusToast';
|
|
||||||
statusToast.className = 'backend-status-toast';
|
|
||||||
document.body.appendChild(statusToast);
|
|
||||||
}
|
|
||||||
|
|
||||||
statusToast.textContent = message;
|
|
||||||
statusToast.className = `backend-status-toast ${type}`;
|
|
||||||
statusToast.classList.add('show');
|
|
||||||
|
|
||||||
// Auto-hide success messages, keep warnings and errors longer
|
|
||||||
const timeout = type === 'success' ? 3000 : 8000;
|
|
||||||
setTimeout(() => {
|
|
||||||
statusToast.classList.remove('show');
|
|
||||||
}, timeout);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show global error message
|
// Show global error message (uses enhanced toast system)
|
||||||
showGlobalError(message) {
|
showGlobalError(message) {
|
||||||
// Create error toast if it doesn't exist
|
toastManager.error(message, { duration: 6000 });
|
||||||
let errorToast = document.getElementById('globalErrorToast');
|
|
||||||
if (!errorToast) {
|
|
||||||
errorToast = document.createElement('div');
|
|
||||||
errorToast.id = 'globalErrorToast';
|
|
||||||
errorToast.className = 'error-toast';
|
|
||||||
document.body.appendChild(errorToast);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorToast.textContent = message;
|
|
||||||
errorToast.classList.add('show');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
errorToast.classList.remove('show');
|
|
||||||
}, 5000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up resources
|
// Clean up resources
|
||||||
@@ -326,9 +431,29 @@ class WiFiDensePoseApp {
|
|||||||
|
|
||||||
// Disconnect all WebSocket connections
|
// Disconnect all WebSocket connections
|
||||||
wsService.disconnectAll();
|
wsService.disconnectAll();
|
||||||
|
|
||||||
// Stop health monitoring
|
// Stop health monitoring
|
||||||
healthService.dispose();
|
healthService.dispose();
|
||||||
|
|
||||||
|
// Dispose enhancements
|
||||||
|
if (this.keyboardShortcuts) this.keyboardShortcuts.dispose();
|
||||||
|
if (this.perfMonitor) this.perfMonitor.dispose();
|
||||||
|
if (this.themeToggle) this.themeToggle.dispose();
|
||||||
|
if (this.commandPalette) this.commandPalette.dispose();
|
||||||
|
if (this.activityLog) this.activityLog.dispose();
|
||||||
|
if (this.dataExport) this.dataExport.dispose();
|
||||||
|
if (this.fullscreenManager) this.fullscreenManager.dispose();
|
||||||
|
if (this.connectionStatus) this.connectionStatus.dispose();
|
||||||
|
if (this.mobileNav) this.mobileNav.dispose();
|
||||||
|
if (this.router) this.router.dispose();
|
||||||
|
if (this.onboarding) this.onboarding.dispose();
|
||||||
|
if (this.idleManager) this.idleManager.dispose();
|
||||||
|
if (this.notificationCenter) this.notificationCenter.dispose();
|
||||||
|
if (this.screenshotTool) this.screenshotTool.dispose();
|
||||||
|
if (this.uptimeClock) this.uptimeClock.dispose();
|
||||||
|
if (this.quickSettings) this.quickSettings.dispose();
|
||||||
|
i18n.dispose();
|
||||||
|
toastManager.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
|
|||||||
@@ -19,6 +19,33 @@ export class TabManager {
|
|||||||
tab.addEventListener('click', () => this.switchTab(tab));
|
tab.addEventListener('click', () => this.switchTab(tab));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Arrow key navigation within tab bar (WCAG)
|
||||||
|
const nav = this.container.querySelector('.nav-tabs');
|
||||||
|
if (nav) {
|
||||||
|
nav.addEventListener('keydown', (e) => {
|
||||||
|
const buttonTabs = this.tabs.filter(t => t.tagName === 'BUTTON' && !t.disabled);
|
||||||
|
const currentIndex = buttonTabs.indexOf(document.activeElement);
|
||||||
|
if (currentIndex === -1) return;
|
||||||
|
|
||||||
|
let nextIndex = -1;
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
|
||||||
|
nextIndex = (currentIndex + 1) % buttonTabs.length;
|
||||||
|
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||||
|
nextIndex = (currentIndex - 1 + buttonTabs.length) % buttonTabs.length;
|
||||||
|
} else if (e.key === 'Home') {
|
||||||
|
nextIndex = 0;
|
||||||
|
} else if (e.key === 'End') {
|
||||||
|
nextIndex = buttonTabs.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextIndex >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
buttonTabs[nextIndex].focus();
|
||||||
|
this.switchTab(buttonTabs[nextIndex]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Activate first tab if none active
|
// Activate first tab if none active
|
||||||
const activeTab = this.tabs.find(tab => tab.classList.contains('active'));
|
const activeTab = this.tabs.find(tab => tab.classList.contains('active'));
|
||||||
if (activeTab) {
|
if (activeTab) {
|
||||||
@@ -36,14 +63,22 @@ export class TabManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update tab states
|
// Update tab states and ARIA attributes
|
||||||
this.tabs.forEach(tab => {
|
this.tabs.forEach(tab => {
|
||||||
tab.classList.toggle('active', tab === tabElement);
|
const isActive = tab === tabElement;
|
||||||
|
tab.classList.toggle('active', isActive);
|
||||||
|
if (tab.hasAttribute('aria-selected')) {
|
||||||
|
tab.setAttribute('aria-selected', String(isActive));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update content visibility
|
// Update content visibility and ARIA
|
||||||
this.tabContents.forEach(content => {
|
this.tabContents.forEach(content => {
|
||||||
content.classList.toggle('active', content.id === tabId);
|
const isActive = content.id === tabId;
|
||||||
|
content.classList.toggle('active', isActive);
|
||||||
|
if (content.hasAttribute('role')) {
|
||||||
|
content.setAttribute('aria-hidden', String(!isActive));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update active tab
|
// Update active tab
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>RuView Icon Generator</title></head>
|
||||||
|
<body>
|
||||||
|
<p>Open this file in a browser and right-click to save the canvas images as icon-192.png and icon-512.png</p>
|
||||||
|
<canvas id="c192" width="192" height="192"></canvas>
|
||||||
|
<canvas id="c512" width="512" height="512"></canvas>
|
||||||
|
<script>
|
||||||
|
function drawIcon(canvas) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const s = canvas.width;
|
||||||
|
// Background
|
||||||
|
ctx.fillStyle = '#1f2121';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(0, 0, s, s, s * 0.15);
|
||||||
|
ctx.fill();
|
||||||
|
// WiFi arcs
|
||||||
|
ctx.strokeStyle = '#32b8c6';
|
||||||
|
ctx.lineWidth = s * 0.035;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
const cx = s * 0.5, cy = s * 0.55;
|
||||||
|
[0.35, 0.25, 0.15].forEach(r => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, s * r, -Math.PI * 0.75, -Math.PI * 0.25);
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
// Center dot
|
||||||
|
ctx.fillStyle = '#32b8c6';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, s * 0.03, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
// Person silhouette
|
||||||
|
ctx.strokeStyle = '#21808d';
|
||||||
|
ctx.lineWidth = s * 0.025;
|
||||||
|
// Head
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy - s * 0.15, s * 0.045, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
// Body
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy - s * 0.1);
|
||||||
|
ctx.lineTo(cx, cy + s * 0.05);
|
||||||
|
ctx.stroke();
|
||||||
|
// Arms
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx - s * 0.08, cy - s * 0.04);
|
||||||
|
ctx.lineTo(cx + s * 0.08, cy - s * 0.04);
|
||||||
|
ctx.stroke();
|
||||||
|
// Legs
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy + s * 0.05);
|
||||||
|
ctx.lineTo(cx - s * 0.06, cy + s * 0.15);
|
||||||
|
ctx.moveTo(cx, cy + s * 0.05);
|
||||||
|
ctx.lineTo(cx + s * 0.06, cy + s * 0.15);
|
||||||
|
ctx.stroke();
|
||||||
|
// Text
|
||||||
|
ctx.fillStyle = '#f5f5f5';
|
||||||
|
ctx.font = `bold ${s * 0.08}px sans-serif`;
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('RuView', cx, s * 0.88);
|
||||||
|
}
|
||||||
|
drawIcon(document.getElementById('c192'));
|
||||||
|
drawIcon(document.getElementById('c512'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+38
-30
@@ -3,40 +3,48 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#21808d">
|
||||||
|
<meta name="description" content="WiFi-based human pose estimation, vital sign detection, and presence sensing through walls">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<title>WiFi DensePose: Human Tracking Through Walls</title>
|
<title>WiFi DensePose: Human Tracking Through Walls</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<link rel="manifest" href="manifest.json">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<!-- Skip to main content link for keyboard/screen reader users -->
|
||||||
|
<a href="#dashboard" class="skip-to-content">Skip to main content</a>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="header">
|
<header class="header" role="banner">
|
||||||
<h1>WiFi DensePose</h1>
|
<h1>WiFi DensePose</h1>
|
||||||
<p class="subtitle">Human Tracking Through Walls Using WiFi Signals</p>
|
<p class="subtitle" data-i18n="dashboard.subtitle">Human Tracking Through Walls Using WiFi Signals</p>
|
||||||
<div class="header-info">
|
<div class="header-info">
|
||||||
<span class="api-version"></span>
|
<span class="api-version" aria-label="API version"></span>
|
||||||
<span class="api-environment"></span>
|
<span class="api-environment" aria-label="Environment"></span>
|
||||||
<span class="overall-health"></span>
|
<span class="overall-health" role="status" aria-live="polite" aria-label="System health"></span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav class="nav-tabs">
|
<nav class="nav-tabs" role="tablist" aria-label="Main navigation">
|
||||||
<button class="nav-tab active" data-tab="dashboard">Dashboard</button>
|
<button class="nav-tab active" data-tab="dashboard" role="tab" aria-selected="true" aria-controls="dashboard">Dashboard</button>
|
||||||
<button class="nav-tab" data-tab="hardware">Hardware</button>
|
<button class="nav-tab" data-tab="hardware" role="tab" aria-selected="false" aria-controls="hardware">Hardware</button>
|
||||||
<button class="nav-tab" data-tab="demo">Live Demo</button>
|
<button class="nav-tab" data-tab="demo" role="tab" aria-selected="false" aria-controls="demo">Live Demo</button>
|
||||||
<button class="nav-tab" data-tab="architecture">Architecture</button>
|
<button class="nav-tab" data-tab="architecture" role="tab" aria-selected="false" aria-controls="architecture">Architecture</button>
|
||||||
<button class="nav-tab" data-tab="performance">Performance</button>
|
<button class="nav-tab" data-tab="performance" role="tab" aria-selected="false" aria-controls="performance">Performance</button>
|
||||||
<button class="nav-tab" data-tab="applications">Applications</button>
|
<button class="nav-tab" data-tab="applications" role="tab" aria-selected="false" aria-controls="applications">Applications</button>
|
||||||
<button class="nav-tab" data-tab="sensing">Sensing</button>
|
<button class="nav-tab" data-tab="sensing" role="tab" aria-selected="false" aria-controls="sensing">Sensing</button>
|
||||||
<button class="nav-tab" data-tab="training">Training</button>
|
<button class="nav-tab" data-tab="training" role="tab" aria-selected="false" aria-controls="training">Training</button>
|
||||||
<a href="pose-fusion.html" class="nav-tab" style="text-decoration:none">Pose Fusion</a>
|
<a href="pose-fusion.html" class="nav-tab" style="text-decoration:none">Pose Fusion</a>
|
||||||
<a href="observatory.html" class="nav-tab" style="text-decoration:none">Observatory</a>
|
<a href="observatory.html" class="nav-tab" style="text-decoration:none">Observatory</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Dashboard Tab -->
|
<!-- Dashboard Tab -->
|
||||||
<section id="dashboard" class="tab-content active">
|
<section id="dashboard" class="tab-content active" role="tabpanel" aria-labelledby="dashboard">
|
||||||
<div class="hero-section">
|
<div class="hero-section">
|
||||||
<h2>Revolutionary WiFi-Based Human Pose Detection</h2>
|
<h2 data-i18n="dashboard.title">Revolutionary WiFi-Based Human Pose Detection</h2>
|
||||||
<p class="hero-description">
|
<p class="hero-description">
|
||||||
AI can track your full-body movement through walls using just WiFi signals.
|
AI can track your full-body movement through walls using just WiFi signals.
|
||||||
Researchers at Carnegie Mellon have trained a neural network to turn basic WiFi
|
Researchers at Carnegie Mellon have trained a neural network to turn basic WiFi
|
||||||
@@ -48,7 +56,7 @@
|
|||||||
|
|
||||||
<!-- Live Status Panel -->
|
<!-- Live Status Panel -->
|
||||||
<div class="live-status-panel">
|
<div class="live-status-panel">
|
||||||
<h3>System Status</h3>
|
<h3 data-i18n="dashboard.status">System Status</h3>
|
||||||
<div class="status-grid">
|
<div class="status-grid">
|
||||||
<div class="component-status" data-component="api">
|
<div class="component-status" data-component="api">
|
||||||
<span class="component-name">API Server</span>
|
<span class="component-name">API Server</span>
|
||||||
@@ -80,24 +88,24 @@
|
|||||||
|
|
||||||
<!-- System Metrics -->
|
<!-- System Metrics -->
|
||||||
<div class="system-metrics-panel">
|
<div class="system-metrics-panel">
|
||||||
<h3>System Metrics</h3>
|
<h3 data-i18n="dashboard.metrics">System Metrics</h3>
|
||||||
<div class="metrics-grid">
|
<div class="metrics-grid">
|
||||||
<div class="metric-item">
|
<div class="metric-item">
|
||||||
<span class="metric-label">CPU Usage</span>
|
<span class="metric-label" data-i18n="metrics.cpu">CPU Usage</span>
|
||||||
<div class="progress-bar" data-type="cpu">
|
<div class="progress-bar" data-type="cpu">
|
||||||
<div class="progress-fill normal" style="width: 0%"></div>
|
<div class="progress-fill normal" style="width: 0%"></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="cpu-usage">0%</span>
|
<span class="cpu-usage">0%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-item">
|
<div class="metric-item">
|
||||||
<span class="metric-label">Memory Usage</span>
|
<span class="metric-label" data-i18n="metrics.memory">Memory Usage</span>
|
||||||
<div class="progress-bar" data-type="memory">
|
<div class="progress-bar" data-type="memory">
|
||||||
<div class="progress-fill normal" style="width: 0%"></div>
|
<div class="progress-fill normal" style="width: 0%"></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="memory-usage">0%</span>
|
<span class="memory-usage">0%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-item">
|
<div class="metric-item">
|
||||||
<span class="metric-label">Disk Usage</span>
|
<span class="metric-label" data-i18n="metrics.disk">Disk Usage</span>
|
||||||
<div class="progress-bar" data-type="disk">
|
<div class="progress-bar" data-type="disk">
|
||||||
<div class="progress-fill normal" style="width: 0%"></div>
|
<div class="progress-fill normal" style="width: 0%"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,13 +116,13 @@
|
|||||||
|
|
||||||
<!-- Features Status -->
|
<!-- Features Status -->
|
||||||
<div class="features-panel">
|
<div class="features-panel">
|
||||||
<h3>Features</h3>
|
<h3 data-i18n="dashboard.features">Features</h3>
|
||||||
<div class="features-status"></div>
|
<div class="features-status"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Live Statistics -->
|
<!-- Live Statistics -->
|
||||||
<div class="live-stats-panel">
|
<div class="live-stats-panel">
|
||||||
<h3>Live Statistics</h3>
|
<h3 data-i18n="dashboard.liveStats">Live Statistics</h3>
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-label">Active Persons</span>
|
<span class="stat-label">Active Persons</span>
|
||||||
@@ -181,7 +189,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Hardware Tab -->
|
<!-- Hardware Tab -->
|
||||||
<section id="hardware" class="tab-content">
|
<section id="hardware" class="tab-content" role="tabpanel" aria-labelledby="hardware" aria-hidden="true">
|
||||||
<h2>Hardware Configuration</h2>
|
<h2>Hardware Configuration</h2>
|
||||||
|
|
||||||
<div class="hardware-grid">
|
<div class="hardware-grid">
|
||||||
@@ -259,7 +267,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Demo Tab -->
|
<!-- Demo Tab -->
|
||||||
<section id="demo" class="tab-content">
|
<section id="demo" class="tab-content" role="tabpanel" aria-labelledby="demo" aria-hidden="true">
|
||||||
<h2>Live Demonstration</h2>
|
<h2>Live Demonstration</h2>
|
||||||
|
|
||||||
<div class="demo-controls">
|
<div class="demo-controls">
|
||||||
@@ -312,7 +320,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Architecture Tab -->
|
<!-- Architecture Tab -->
|
||||||
<section id="architecture" class="tab-content">
|
<section id="architecture" class="tab-content" role="tabpanel" aria-labelledby="architecture" aria-hidden="true">
|
||||||
<h2>System Architecture</h2>
|
<h2>System Architecture</h2>
|
||||||
|
|
||||||
<div class="architecture-flow">
|
<div class="architecture-flow">
|
||||||
@@ -350,7 +358,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Performance Tab -->
|
<!-- Performance Tab -->
|
||||||
<section id="performance" class="tab-content">
|
<section id="performance" class="tab-content" role="tabpanel" aria-labelledby="performance" aria-hidden="true">
|
||||||
<h2>Performance Analysis</h2>
|
<h2>Performance Analysis</h2>
|
||||||
|
|
||||||
<div class="performance-chart">
|
<div class="performance-chart">
|
||||||
@@ -422,7 +430,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Applications Tab -->
|
<!-- Applications Tab -->
|
||||||
<section id="applications" class="tab-content">
|
<section id="applications" class="tab-content" role="tabpanel" aria-labelledby="applications" aria-hidden="true">
|
||||||
<h2>Real-World Applications</h2>
|
<h2>Real-World Applications</h2>
|
||||||
|
|
||||||
<div class="applications-grid">
|
<div class="applications-grid">
|
||||||
@@ -489,10 +497,10 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Sensing Tab -->
|
<!-- Sensing Tab -->
|
||||||
<section id="sensing" class="tab-content"></section>
|
<section id="sensing" class="tab-content" role="tabpanel" aria-labelledby="sensing" aria-hidden="true"></section>
|
||||||
|
|
||||||
<!-- Training Tab -->
|
<!-- Training Tab -->
|
||||||
<section id="training" class="tab-content">
|
<section id="training" class="tab-content" role="tabpanel" aria-labelledby="training" aria-hidden="true">
|
||||||
<div class="tab-header">
|
<div class="tab-header">
|
||||||
<h2>Model Training</h2>
|
<h2>Model Training</h2>
|
||||||
<p>Record CSI data, train pose estimation models, and manage .rvf files</p>
|
<p>Record CSI data, train pose estimation models, and manage .rvf files</p>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "RuView - WiFi DensePose",
|
||||||
|
"short_name": "RuView",
|
||||||
|
"description": "WiFi-based human pose estimation, vital sign detection, and presence sensing through walls",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#1f2121",
|
||||||
|
"theme_color": "#21808d",
|
||||||
|
"orientation": "any",
|
||||||
|
"categories": ["utilities", "medical"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1741
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
|||||||
|
// RuView Service Worker - Offline caching for the dashboard shell
|
||||||
|
// Strategy: Network-first for API calls, Cache-first for static assets
|
||||||
|
|
||||||
|
const CACHE_NAME = 'ruview-v1';
|
||||||
|
const SHELL_ASSETS = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/style.css',
|
||||||
|
'/app.js',
|
||||||
|
'/config/api.config.js',
|
||||||
|
'/components/TabManager.js',
|
||||||
|
'/components/DashboardTab.js',
|
||||||
|
'/components/HardwareTab.js',
|
||||||
|
'/components/LiveDemoTab.js',
|
||||||
|
'/components/SensingTab.js',
|
||||||
|
'/components/PoseDetectionCanvas.js',
|
||||||
|
'/services/api.service.js',
|
||||||
|
'/services/websocket.service.js',
|
||||||
|
'/services/health.service.js',
|
||||||
|
'/services/sensing.service.js',
|
||||||
|
'/services/pose.service.js',
|
||||||
|
'/services/stream.service.js',
|
||||||
|
'/utils/backend-detector.js',
|
||||||
|
'/utils/keyboard-shortcuts.js',
|
||||||
|
'/utils/perf-monitor.js',
|
||||||
|
'/utils/toast.js',
|
||||||
|
'/utils/theme-toggle.js',
|
||||||
|
'/utils/command-palette.js',
|
||||||
|
'/utils/activity-log.js',
|
||||||
|
'/utils/data-export.js',
|
||||||
|
'/utils/fullscreen.js',
|
||||||
|
'/utils/connection-status.js',
|
||||||
|
'/utils/mobile-nav.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install - cache shell assets
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => {
|
||||||
|
return cache.addAll(SHELL_ASSETS).catch((err) => {
|
||||||
|
// Don't fail install if some assets are missing (dev mode)
|
||||||
|
console.warn('[SW] Some assets failed to cache:', err);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate - clean old caches
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) => {
|
||||||
|
return Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key !== CACHE_NAME)
|
||||||
|
.map((key) => caches.delete(key))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch - network-first for API, cache-first for static
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Skip non-GET requests
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
|
// Skip WebSocket upgrade requests
|
||||||
|
if (request.headers.get('Upgrade') === 'websocket') return;
|
||||||
|
|
||||||
|
// Skip cross-origin requests
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// API calls: network-first with cache fallback
|
||||||
|
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/health/')) {
|
||||||
|
event.respondWith(networkFirst(request));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets: cache-first with network fallback
|
||||||
|
event.respondWith(cacheFirst(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function cacheFirst(request) {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
// Return offline fallback for HTML navigation
|
||||||
|
if (request.headers.get('Accept')?.includes('text/html')) {
|
||||||
|
const fallback = await caches.match('/index.html');
|
||||||
|
if (fallback) return fallback;
|
||||||
|
}
|
||||||
|
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkFirst(request) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
return new Response(JSON.stringify({ error: 'offline' }), {
|
||||||
|
status: 503,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>RuView UI - Unit Tests</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 24px; }
|
||||||
|
h1 { font-size: 20px; margin-bottom: 4px; color: #32b8c6; }
|
||||||
|
.subtitle { font-size: 13px; color: #a7a9a9; margin-bottom: 20px; }
|
||||||
|
.suite { margin-bottom: 16px; }
|
||||||
|
.suite-name { font-size: 14px; font-weight: 600; margin-bottom: 6px; color: #a7a9a9; }
|
||||||
|
.test { padding: 4px 0 4px 16px; font-size: 13px; font-family: monospace; }
|
||||||
|
.pass { color: #32b8c6; }
|
||||||
|
.fail { color: #ff5459; }
|
||||||
|
.pass::before { content: "PASS "; font-weight: bold; }
|
||||||
|
.fail::before { content: "FAIL "; font-weight: bold; }
|
||||||
|
.summary { margin-top: 24px; padding: 12px; border-top: 1px solid #333; font-size: 14px; font-weight: 600; }
|
||||||
|
.error-detail { color: #ff8a8a; font-size: 12px; padding-left: 32px; white-space: pre-wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>RuView UI - Unit Tests</h1>
|
||||||
|
<p class="subtitle">Tests for UI components and utility modules</p>
|
||||||
|
<div id="output"></div>
|
||||||
|
<div id="summary" class="summary"></div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
// ---- Minimal test framework (zero deps) ----
|
||||||
|
const results = [];
|
||||||
|
let currentSuite = '';
|
||||||
|
|
||||||
|
function describe(name, fn) { currentSuite = name; fn(); }
|
||||||
|
|
||||||
|
function it(name, fn) {
|
||||||
|
try { fn(); results.push({ suite: currentSuite, name, passed: true }); }
|
||||||
|
catch (e) { results.push({ suite: currentSuite, name, passed: false, error: e.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function expect(actual) {
|
||||||
|
return {
|
||||||
|
toBe(exp) { if (actual !== exp) throw new Error(`Expected ${JSON.stringify(exp)}, got ${JSON.stringify(actual)}`); },
|
||||||
|
toEqual(exp) { if (JSON.stringify(actual) !== JSON.stringify(exp)) throw new Error(`Expected ${JSON.stringify(exp)}, got ${JSON.stringify(actual)}`); },
|
||||||
|
toBeTruthy() { if (!actual) throw new Error(`Expected truthy, got ${JSON.stringify(actual)}`); },
|
||||||
|
toBeFalsy() { if (actual) throw new Error(`Expected falsy, got ${JSON.stringify(actual)}`); },
|
||||||
|
toBeGreaterThan(n) { if (!(actual > n)) throw new Error(`Expected ${actual} > ${n}`); },
|
||||||
|
toContain(str) { if (typeof actual === 'string' ? !actual.includes(str) : !actual.includes(str)) throw new Error(`Expected to contain "${str}"`); },
|
||||||
|
not: {
|
||||||
|
toBe(exp) { if (actual === exp) throw new Error(`Expected not ${JSON.stringify(exp)}`); },
|
||||||
|
toContain(str) { if (typeof actual === 'string' && actual.includes(str)) throw new Error(`Expected not to contain "${str}"`); }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockDOM() {
|
||||||
|
const c = document.createElement('div');
|
||||||
|
c.className = 'container';
|
||||||
|
c.innerHTML = `
|
||||||
|
<header class="header"><div class="header-info"></div></header>
|
||||||
|
<nav class="nav-tabs">
|
||||||
|
<button class="nav-tab active" data-tab="dashboard" role="tab" aria-selected="true">Dashboard</button>
|
||||||
|
<button class="nav-tab" data-tab="hardware" role="tab" aria-selected="false">Hardware</button>
|
||||||
|
<button class="nav-tab" data-tab="demo" role="tab" aria-selected="false">Live Demo</button>
|
||||||
|
</nav>
|
||||||
|
<section id="dashboard" class="tab-content active" role="tabpanel"></section>
|
||||||
|
<section id="hardware" class="tab-content" role="tabpanel"></section>
|
||||||
|
<section id="demo" class="tab-content" role="tabpanel"></section>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(c);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== ToastManager =====
|
||||||
|
const { ToastManager } = await import('../utils/toast.js');
|
||||||
|
|
||||||
|
describe('ToastManager', () => {
|
||||||
|
it('creates container with role=region on init', () => {
|
||||||
|
const tm = new ToastManager();
|
||||||
|
tm.init();
|
||||||
|
expect(tm.container.getAttribute('role')).toBe('region');
|
||||||
|
expect(tm.container.getAttribute('aria-live')).toBe('polite');
|
||||||
|
tm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show() returns unique incremental ids', () => {
|
||||||
|
const tm = new ToastManager();
|
||||||
|
tm.init();
|
||||||
|
const a = tm.show('A'); const b = tm.show('B');
|
||||||
|
expect(b).toBeGreaterThan(a);
|
||||||
|
tm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismiss() removes toast from list', () => {
|
||||||
|
const tm = new ToastManager();
|
||||||
|
tm.init();
|
||||||
|
const id = tm.show('X', { duration: 0 });
|
||||||
|
expect(tm.toasts.length).toBe(1);
|
||||||
|
tm.dismiss(id);
|
||||||
|
expect(tm.toasts.length).toBe(0);
|
||||||
|
tm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismiss() is safe to call with unknown id', () => {
|
||||||
|
const tm = new ToastManager();
|
||||||
|
tm.init();
|
||||||
|
tm.dismiss(99999); // should not throw
|
||||||
|
expect(tm.toasts.length).toBe(0);
|
||||||
|
tm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('success/error/warning/info create correct types', () => {
|
||||||
|
const tm = new ToastManager();
|
||||||
|
tm.init();
|
||||||
|
tm.success('a'); tm.error('b'); tm.warning('c'); tm.info('d');
|
||||||
|
expect(tm.toasts.length).toBe(4);
|
||||||
|
tm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes HTML entities to prevent XSS', () => {
|
||||||
|
const tm = new ToastManager();
|
||||||
|
const safe = tm.escapeHtml('<img src=x onerror=alert(1)>');
|
||||||
|
expect(safe).not.toContain('<img');
|
||||||
|
expect(safe).toContain('<img');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stacks multiple toasts in container', () => {
|
||||||
|
const tm = new ToastManager();
|
||||||
|
tm.init();
|
||||||
|
tm.show('1', { duration: 0 });
|
||||||
|
tm.show('2', { duration: 0 });
|
||||||
|
tm.show('3', { duration: 0 });
|
||||||
|
expect(tm.container.children.length).toBe(3);
|
||||||
|
tm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispose() removes container from DOM', () => {
|
||||||
|
const tm = new ToastManager();
|
||||||
|
tm.init();
|
||||||
|
tm.show('Z', { duration: 0 });
|
||||||
|
const c = tm.container;
|
||||||
|
tm.dispose();
|
||||||
|
expect(c.parentNode).toBeFalsy();
|
||||||
|
expect(tm.toasts.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== ThemeToggle =====
|
||||||
|
const { ThemeToggle } = await import('../utils/theme-toggle.js');
|
||||||
|
|
||||||
|
describe('ThemeToggle', () => {
|
||||||
|
const dom = mockDOM();
|
||||||
|
|
||||||
|
it('detects system theme as dark or light', () => {
|
||||||
|
const tt = new ThemeToggle();
|
||||||
|
const t = tt.getSystemTheme();
|
||||||
|
expect(t === 'dark' || t === 'light').toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates button with aria-label in header', () => {
|
||||||
|
const tt = new ThemeToggle();
|
||||||
|
tt.init();
|
||||||
|
expect(tt.button).toBeTruthy();
|
||||||
|
expect(tt.button.getAttribute('aria-label')).toBeTruthy();
|
||||||
|
tt.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggle() alternates between dark and light', () => {
|
||||||
|
const tt = new ThemeToggle();
|
||||||
|
tt.init();
|
||||||
|
const initial = tt.currentTheme;
|
||||||
|
tt.toggle();
|
||||||
|
expect(tt.currentTheme).not.toBe(initial);
|
||||||
|
tt.toggle();
|
||||||
|
expect(tt.currentTheme).toBe(initial);
|
||||||
|
tt.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applyTheme() sets data-color-scheme on <html>', () => {
|
||||||
|
const tt = new ThemeToggle();
|
||||||
|
tt.applyTheme('dark');
|
||||||
|
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('dark');
|
||||||
|
tt.applyTheme('light');
|
||||||
|
expect(document.documentElement.getAttribute('data-color-scheme')).toBe('light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists and retrieves theme from localStorage', () => {
|
||||||
|
const tt = new ThemeToggle();
|
||||||
|
tt.saveTheme('dark');
|
||||||
|
expect(tt.getSavedTheme()).toBe('dark');
|
||||||
|
tt.saveTheme('light');
|
||||||
|
expect(tt.getSavedTheme()).toBe('light');
|
||||||
|
localStorage.removeItem('ruview-theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== KeyboardShortcuts =====
|
||||||
|
const { KeyboardShortcuts } = await import('../utils/keyboard-shortcuts.js');
|
||||||
|
|
||||||
|
describe('KeyboardShortcuts', () => {
|
||||||
|
it('has default shortcuts for ?, Escape, and number keys', () => {
|
||||||
|
const ks = new KeyboardShortcuts(null);
|
||||||
|
expect(ks.shortcuts.has('?')).toBeTruthy();
|
||||||
|
expect(ks.shortcuts.has('Escape')).toBeTruthy();
|
||||||
|
expect(ks.shortcuts.has('1')).toBeTruthy();
|
||||||
|
expect(ks.shortcuts.has('8')).toBeTruthy();
|
||||||
|
ks.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('register() adds custom handler', () => {
|
||||||
|
const ks = new KeyboardShortcuts(null);
|
||||||
|
let ran = false;
|
||||||
|
ks.register('z', 'Test', () => { ran = true; });
|
||||||
|
expect(ks.shortcuts.has('z')).toBeTruthy();
|
||||||
|
ks.shortcuts.get('z').handler();
|
||||||
|
expect(ran).toBeTruthy();
|
||||||
|
ks.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formatKey() maps Escape to Esc', () => {
|
||||||
|
const ks = new KeyboardShortcuts(null);
|
||||||
|
expect(ks.formatKey('Escape')).toBe('Esc');
|
||||||
|
expect(ks.formatKey('a')).toBe('A');
|
||||||
|
ks.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('init() creates dialog overlay', () => {
|
||||||
|
const ks = new KeyboardShortcuts(null);
|
||||||
|
ks.init();
|
||||||
|
expect(ks.overlay).toBeTruthy();
|
||||||
|
expect(ks.overlay.getAttribute('role')).toBe('dialog');
|
||||||
|
expect(ks.overlay.getAttribute('aria-modal')).toBe('true');
|
||||||
|
ks.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showHelp/hideHelp toggles overlay visibility', () => {
|
||||||
|
const ks = new KeyboardShortcuts(null);
|
||||||
|
ks.init();
|
||||||
|
ks.showHelp();
|
||||||
|
expect(ks.helpVisible).toBeTruthy();
|
||||||
|
expect(ks.overlay.classList.contains('visible')).toBeTruthy();
|
||||||
|
ks.hideHelp();
|
||||||
|
expect(ks.helpVisible).toBeFalsy();
|
||||||
|
ks.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildHelpHTML() includes Navigation/Actions/General groups', () => {
|
||||||
|
const ks = new KeyboardShortcuts(null);
|
||||||
|
const html = ks.buildHelpHTML();
|
||||||
|
expect(html).toContain('Navigation');
|
||||||
|
expect(html).toContain('Actions');
|
||||||
|
expect(html).toContain('General');
|
||||||
|
ks.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispose() removes overlay from DOM', () => {
|
||||||
|
const ks = new KeyboardShortcuts(null);
|
||||||
|
ks.init();
|
||||||
|
const o = ks.overlay;
|
||||||
|
ks.dispose();
|
||||||
|
expect(o.parentNode).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== PerfMonitor =====
|
||||||
|
const { PerfMonitor } = await import('../utils/perf-monitor.js');
|
||||||
|
|
||||||
|
describe('PerfMonitor', () => {
|
||||||
|
it('creates panel with role=status and aria-label', () => {
|
||||||
|
const pm = new PerfMonitor();
|
||||||
|
pm.init();
|
||||||
|
expect(pm.panel.getAttribute('role')).toBe('status');
|
||||||
|
expect(pm.panel.getAttribute('aria-label')).toBe('Performance monitor');
|
||||||
|
pm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('show/hide updates visible state', () => {
|
||||||
|
const pm = new PerfMonitor();
|
||||||
|
pm.init();
|
||||||
|
pm.show();
|
||||||
|
expect(pm.visible).toBeTruthy();
|
||||||
|
expect(pm.panel.classList.contains('visible')).toBeTruthy();
|
||||||
|
pm.hide();
|
||||||
|
expect(pm.visible).toBeFalsy();
|
||||||
|
pm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggle() flips visibility', () => {
|
||||||
|
const pm = new PerfMonitor();
|
||||||
|
pm.init();
|
||||||
|
pm.toggle();
|
||||||
|
expect(pm.visible).toBeTruthy();
|
||||||
|
pm.toggle();
|
||||||
|
expect(pm.visible).toBeFalsy();
|
||||||
|
pm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateMetric() sets text and CSS class', () => {
|
||||||
|
const pm = new PerfMonitor();
|
||||||
|
pm.init();
|
||||||
|
pm.updateMetric('fps', 60, 'ok');
|
||||||
|
const el = pm.panel.querySelector('[data-metric="fps"]');
|
||||||
|
expect(el.textContent).toBe('60');
|
||||||
|
expect(el.className).toContain('perf-ok');
|
||||||
|
pm.updateMetric('fps', 15, 'warning');
|
||||||
|
expect(el.className).toContain('perf-warning');
|
||||||
|
pm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pushSpark() appends data and caps at 60', () => {
|
||||||
|
const pm = new PerfMonitor();
|
||||||
|
pm.init();
|
||||||
|
for (let i = 0; i < 70; i++) pm.pushSpark('fps', i, 0, 120);
|
||||||
|
expect(pm.sparkData.fps.length).toBe(60);
|
||||||
|
pm.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispose() cleans up panel', () => {
|
||||||
|
const pm = new PerfMonitor();
|
||||||
|
pm.init();
|
||||||
|
pm.show();
|
||||||
|
const p = pm.panel;
|
||||||
|
pm.dispose();
|
||||||
|
expect(p.parentNode).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== TabManager =====
|
||||||
|
const { TabManager } = await import('../components/TabManager.js');
|
||||||
|
|
||||||
|
describe('TabManager', () => {
|
||||||
|
it('initializes and finds all tabs', () => {
|
||||||
|
const d = mockDOM();
|
||||||
|
const tm = new TabManager(d);
|
||||||
|
tm.init();
|
||||||
|
expect(tm.tabs.length).toBe(3);
|
||||||
|
expect(tm.activeTab).toBe('dashboard');
|
||||||
|
d.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switchToTab() changes active tab', () => {
|
||||||
|
const d = mockDOM();
|
||||||
|
const tm = new TabManager(d);
|
||||||
|
tm.init();
|
||||||
|
tm.switchToTab('hardware');
|
||||||
|
expect(tm.activeTab).toBe('hardware');
|
||||||
|
expect(d.querySelector('[data-tab="hardware"]').classList.contains('active')).toBeTruthy();
|
||||||
|
expect(d.querySelector('[data-tab="dashboard"]').classList.contains('active')).toBeFalsy();
|
||||||
|
d.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates aria-selected on tab switch', () => {
|
||||||
|
const d = mockDOM();
|
||||||
|
const tm = new TabManager(d);
|
||||||
|
tm.init();
|
||||||
|
tm.switchToTab('demo');
|
||||||
|
expect(d.querySelector('[data-tab="dashboard"]').getAttribute('aria-selected')).toBe('false');
|
||||||
|
expect(d.querySelector('[data-tab="demo"]').getAttribute('aria-selected')).toBe('true');
|
||||||
|
d.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onTabChange callbacks with correct args', () => {
|
||||||
|
const d = mockDOM();
|
||||||
|
const tm = new TabManager(d);
|
||||||
|
tm.init();
|
||||||
|
let newId = '', oldId = '';
|
||||||
|
tm.onTabChange((n, o) => { newId = n; oldId = o; });
|
||||||
|
tm.switchToTab('hardware');
|
||||||
|
expect(newId).toBe('hardware');
|
||||||
|
expect(oldId).toBe('dashboard');
|
||||||
|
d.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fire callback when switching to already active tab', () => {
|
||||||
|
const d = mockDOM();
|
||||||
|
const tm = new TabManager(d);
|
||||||
|
tm.init();
|
||||||
|
let count = 0;
|
||||||
|
tm.onTabChange(() => { count++; });
|
||||||
|
tm.switchToTab('dashboard');
|
||||||
|
expect(count).toBe(0);
|
||||||
|
d.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onTabChange() returns unsubscribe function', () => {
|
||||||
|
const d = mockDOM();
|
||||||
|
const tm = new TabManager(d);
|
||||||
|
tm.init();
|
||||||
|
let count = 0;
|
||||||
|
const unsub = tm.onTabChange(() => { count++; });
|
||||||
|
tm.switchToTab('hardware');
|
||||||
|
expect(count).toBe(1);
|
||||||
|
unsub();
|
||||||
|
tm.switchToTab('demo');
|
||||||
|
expect(count).toBe(1); // not incremented
|
||||||
|
d.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setTabEnabled(false) disables tab button', () => {
|
||||||
|
const d = mockDOM();
|
||||||
|
const tm = new TabManager(d);
|
||||||
|
tm.init();
|
||||||
|
tm.setTabEnabled('hardware', false);
|
||||||
|
const btn = d.querySelector('[data-tab="hardware"]');
|
||||||
|
expect(btn.disabled).toBeTruthy();
|
||||||
|
expect(btn.classList.contains('disabled')).toBeTruthy();
|
||||||
|
tm.setTabEnabled('hardware', true);
|
||||||
|
expect(btn.disabled).toBeFalsy();
|
||||||
|
d.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setTabVisible(false) hides tab', () => {
|
||||||
|
const d = mockDOM();
|
||||||
|
const tm = new TabManager(d);
|
||||||
|
tm.init();
|
||||||
|
tm.setTabVisible('demo', false);
|
||||||
|
expect(d.querySelector('[data-tab="demo"]').style.display).toBe('none');
|
||||||
|
tm.setTabVisible('demo', true);
|
||||||
|
expect(d.querySelector('[data-tab="demo"]').style.display).toBe('');
|
||||||
|
d.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setTabBadge() adds/removes badge', () => {
|
||||||
|
const d = mockDOM();
|
||||||
|
const tm = new TabManager(d);
|
||||||
|
tm.init();
|
||||||
|
tm.setTabBadge('hardware', '3');
|
||||||
|
const badge = d.querySelector('[data-tab="hardware"] .tab-badge');
|
||||||
|
expect(badge).toBeTruthy();
|
||||||
|
expect(badge.textContent).toBe('3');
|
||||||
|
tm.setTabBadge('hardware', null);
|
||||||
|
expect(d.querySelector('[data-tab="hardware"] .tab-badge')).toBeFalsy();
|
||||||
|
d.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== RENDER RESULTS =====
|
||||||
|
const output = document.getElementById('output');
|
||||||
|
let lastSuite = '', passed = 0, failed = 0;
|
||||||
|
|
||||||
|
results.forEach(r => {
|
||||||
|
if (r.suite !== lastSuite) {
|
||||||
|
lastSuite = r.suite;
|
||||||
|
const s = document.createElement('div');
|
||||||
|
s.className = 'suite';
|
||||||
|
s.innerHTML = `<div class="suite-name">${r.suite}</div>`;
|
||||||
|
output.appendChild(s);
|
||||||
|
}
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = `test ${r.passed ? 'pass' : 'fail'}`;
|
||||||
|
t.textContent = r.name;
|
||||||
|
output.lastChild.appendChild(t);
|
||||||
|
if (!r.passed) {
|
||||||
|
const e = document.createElement('div');
|
||||||
|
e.className = 'error-detail';
|
||||||
|
e.textContent = r.error;
|
||||||
|
output.lastChild.appendChild(e);
|
||||||
|
}
|
||||||
|
r.passed ? passed++ : failed++;
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = document.getElementById('summary');
|
||||||
|
summary.textContent = `${passed + failed} tests: ${passed} passed, ${failed} failed`;
|
||||||
|
summary.style.color = failed === 0 ? '#32b8c6' : '#ff5459';
|
||||||
|
|
||||||
|
console.info(`[UNIT-TESTS] ${passed + failed} tests: ${passed} passed, ${failed} failed`);
|
||||||
|
if (failed > 0) results.filter(r => !r.passed).forEach(r => console.error(`[FAIL] ${r.suite} > ${r.name}: ${r.error}`));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// Activity Log - Scrollable panel showing system events in real-time
|
||||||
|
// Toggle with 'L' key or command palette
|
||||||
|
|
||||||
|
export class ActivityLog {
|
||||||
|
constructor() {
|
||||||
|
this.panel = null;
|
||||||
|
this.visible = false;
|
||||||
|
this.entries = [];
|
||||||
|
this.maxEntries = 200;
|
||||||
|
this.logBody = null;
|
||||||
|
this.filters = { info: true, warning: true, error: true, connection: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createPanel();
|
||||||
|
this.interceptConsole();
|
||||||
|
document.addEventListener('toggle-activity-log', () => this.toggle());
|
||||||
|
}
|
||||||
|
|
||||||
|
createPanel() {
|
||||||
|
this.panel = document.createElement('div');
|
||||||
|
this.panel.className = 'activity-log';
|
||||||
|
this.panel.setAttribute('role', 'log');
|
||||||
|
this.panel.setAttribute('aria-label', 'Activity log');
|
||||||
|
this.panel.innerHTML = `
|
||||||
|
<div class="activity-log-header">
|
||||||
|
<span class="activity-log-title">Activity Log</span>
|
||||||
|
<div class="activity-log-controls">
|
||||||
|
<button class="activity-log-filter active" data-filter="info" aria-label="Toggle info messages" title="Info">I</button>
|
||||||
|
<button class="activity-log-filter active" data-filter="warning" aria-label="Toggle warnings" title="Warnings">W</button>
|
||||||
|
<button class="activity-log-filter active" data-filter="error" aria-label="Toggle errors" title="Errors">E</button>
|
||||||
|
<button class="activity-log-filter active" data-filter="connection" aria-label="Toggle connection events" title="Connection">C</button>
|
||||||
|
<button class="activity-log-clear" aria-label="Clear log" title="Clear">Clear</button>
|
||||||
|
<button class="activity-log-close" aria-label="Close activity log">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="activity-log-body"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.logBody = this.panel.querySelector('.activity-log-body');
|
||||||
|
|
||||||
|
// Filter toggles
|
||||||
|
this.panel.querySelectorAll('.activity-log-filter').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const filter = btn.dataset.filter;
|
||||||
|
this.filters[filter] = !this.filters[filter];
|
||||||
|
btn.classList.toggle('active', this.filters[filter]);
|
||||||
|
this.rerender();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear button
|
||||||
|
this.panel.querySelector('.activity-log-clear').addEventListener('click', () => {
|
||||||
|
this.entries = [];
|
||||||
|
this.rerender();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close button
|
||||||
|
this.panel.querySelector('.activity-log-close').addEventListener('click', () => this.hide());
|
||||||
|
|
||||||
|
// Make resizable by dragging top edge
|
||||||
|
this.makeResizable();
|
||||||
|
|
||||||
|
document.body.appendChild(this.panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
makeResizable() {
|
||||||
|
let resizing = false;
|
||||||
|
let startY = 0;
|
||||||
|
let startHeight = 0;
|
||||||
|
|
||||||
|
this.panel.addEventListener('mousedown', (e) => {
|
||||||
|
// Only top 5px edge
|
||||||
|
const rect = this.panel.getBoundingClientRect();
|
||||||
|
if (e.clientY - rect.top > 5) return;
|
||||||
|
resizing = true;
|
||||||
|
startY = e.clientY;
|
||||||
|
startHeight = rect.height;
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (!resizing) return;
|
||||||
|
const delta = startY - e.clientY;
|
||||||
|
const newHeight = Math.max(150, Math.min(window.innerHeight * 0.7, startHeight + delta));
|
||||||
|
this.panel.style.height = `${newHeight}px`;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', () => { resizing = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
interceptConsole() {
|
||||||
|
const origInfo = console.info;
|
||||||
|
const origWarn = console.warn;
|
||||||
|
const origError = console.error;
|
||||||
|
|
||||||
|
console.info = (...args) => {
|
||||||
|
origInfo.apply(console, args);
|
||||||
|
this.addEntry('info', args.map(String).join(' '));
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn = (...args) => {
|
||||||
|
origWarn.apply(console, args);
|
||||||
|
const msg = args.map(String).join(' ');
|
||||||
|
const type = msg.includes('[WS-') || msg.includes('connect') ? 'connection' : 'warning';
|
||||||
|
this.addEntry(type, msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...args) => {
|
||||||
|
origError.apply(console, args);
|
||||||
|
this.addEntry('error', args.map(String).join(' '));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
addEntry(type, message) {
|
||||||
|
const entry = {
|
||||||
|
time: new Date(),
|
||||||
|
type,
|
||||||
|
message: this.truncate(message, 300)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.entries.push(entry);
|
||||||
|
if (this.entries.length > this.maxEntries) {
|
||||||
|
this.entries.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.visible && this.filters[type]) {
|
||||||
|
this.appendEntry(entry);
|
||||||
|
// Auto-scroll to bottom
|
||||||
|
this.logBody.scrollTop = this.logBody.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendEntry(entry) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = `activity-log-entry activity-log-${entry.type}`;
|
||||||
|
const time = entry.time.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
el.innerHTML = `<span class="activity-log-time">${time}</span><span class="activity-log-type">${entry.type.toUpperCase().charAt(0)}</span><span class="activity-log-msg">${this.escapeHtml(entry.message)}</span>`;
|
||||||
|
this.logBody.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
rerender() {
|
||||||
|
this.logBody.innerHTML = '';
|
||||||
|
this.entries
|
||||||
|
.filter(e => this.filters[e.type])
|
||||||
|
.forEach(e => this.appendEntry(e));
|
||||||
|
this.logBody.scrollTop = this.logBody.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.visible ? this.hide() : this.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
show() {
|
||||||
|
this.visible = true;
|
||||||
|
this.panel.classList.add('visible');
|
||||||
|
this.rerender();
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
this.visible = false;
|
||||||
|
this.panel.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
truncate(str, max) {
|
||||||
|
return str.length > max ? str.slice(0, max) + '...' : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.hide();
|
||||||
|
if (this.panel?.parentNode) {
|
||||||
|
this.panel.parentNode.removeChild(this.panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
// Command Palette - Ctrl+K / Cmd+K to search and execute commands
|
||||||
|
// Fuzzy search across tabs, actions, and settings
|
||||||
|
|
||||||
|
export class CommandPalette {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app;
|
||||||
|
this.overlay = null;
|
||||||
|
this.input = null;
|
||||||
|
this.results = null;
|
||||||
|
this.visible = false;
|
||||||
|
this.commands = [];
|
||||||
|
this.selectedIndex = 0;
|
||||||
|
this.filteredCommands = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.registerCommands();
|
||||||
|
this.createDOM();
|
||||||
|
this.bindGlobalShortcut();
|
||||||
|
}
|
||||||
|
|
||||||
|
registerCommands() {
|
||||||
|
// Navigation commands
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'dashboard', label: 'Dashboard', icon: 'grid' },
|
||||||
|
{ id: 'hardware', label: 'Hardware', icon: 'cpu' },
|
||||||
|
{ id: 'demo', label: 'Live Demo', icon: 'play' },
|
||||||
|
{ id: 'architecture', label: 'Architecture', icon: 'layers' },
|
||||||
|
{ id: 'performance', label: 'Performance', icon: 'zap' },
|
||||||
|
{ id: 'applications', label: 'Applications', icon: 'box' },
|
||||||
|
{ id: 'sensing', label: 'Sensing', icon: 'wifi' },
|
||||||
|
{ id: 'training', label: 'Training', icon: 'database' },
|
||||||
|
];
|
||||||
|
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
this.commands.push({
|
||||||
|
category: 'Navigation',
|
||||||
|
label: `Go to ${tab.label}`,
|
||||||
|
keywords: [tab.id, tab.label.toLowerCase()],
|
||||||
|
icon: tab.icon,
|
||||||
|
action: () => {
|
||||||
|
const tm = this.app?.getComponent?.('tabManager');
|
||||||
|
if (tm) tm.switchToTab(tab.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// External pages
|
||||||
|
this.commands.push({
|
||||||
|
category: 'Navigation',
|
||||||
|
label: 'Open Pose Fusion',
|
||||||
|
keywords: ['pose', 'fusion', 'camera'],
|
||||||
|
icon: 'external',
|
||||||
|
action: () => { window.location.href = 'pose-fusion.html'; }
|
||||||
|
});
|
||||||
|
this.commands.push({
|
||||||
|
category: 'Navigation',
|
||||||
|
label: 'Open Observatory',
|
||||||
|
keywords: ['observatory', '3d', 'signal'],
|
||||||
|
icon: 'external',
|
||||||
|
action: () => { window.location.href = 'observatory.html'; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
this.commands.push({
|
||||||
|
category: 'Actions',
|
||||||
|
label: 'Toggle Dark/Light Theme',
|
||||||
|
keywords: ['theme', 'dark', 'light', 'mode', 'color'],
|
||||||
|
icon: 'moon',
|
||||||
|
action: () => document.dispatchEvent(new CustomEvent('toggle-theme'))
|
||||||
|
});
|
||||||
|
this.commands.push({
|
||||||
|
category: 'Actions',
|
||||||
|
label: 'Toggle Performance Monitor',
|
||||||
|
keywords: ['perf', 'fps', 'memory', 'performance', 'monitor'],
|
||||||
|
icon: 'activity',
|
||||||
|
action: () => document.dispatchEvent(new CustomEvent('toggle-perf-monitor'))
|
||||||
|
});
|
||||||
|
this.commands.push({
|
||||||
|
category: 'Actions',
|
||||||
|
label: 'Toggle Activity Log',
|
||||||
|
keywords: ['log', 'events', 'activity', 'history'],
|
||||||
|
icon: 'list',
|
||||||
|
action: () => document.dispatchEvent(new CustomEvent('toggle-activity-log'))
|
||||||
|
});
|
||||||
|
this.commands.push({
|
||||||
|
category: 'Actions',
|
||||||
|
label: 'Export Sensor Data',
|
||||||
|
keywords: ['export', 'download', 'csv', 'json', 'data', 'save'],
|
||||||
|
icon: 'download',
|
||||||
|
action: () => document.dispatchEvent(new CustomEvent('export-data'))
|
||||||
|
});
|
||||||
|
this.commands.push({
|
||||||
|
category: 'Actions',
|
||||||
|
label: 'Toggle Fullscreen',
|
||||||
|
keywords: ['fullscreen', 'full', 'screen', 'maximize'],
|
||||||
|
icon: 'maximize',
|
||||||
|
action: () => document.dispatchEvent(new CustomEvent('toggle-fullscreen'))
|
||||||
|
});
|
||||||
|
this.commands.push({
|
||||||
|
category: 'Actions',
|
||||||
|
label: 'Show Keyboard Shortcuts',
|
||||||
|
keywords: ['keyboard', 'shortcuts', 'keys', 'help'],
|
||||||
|
icon: 'keyboard',
|
||||||
|
action: () => document.dispatchEvent(new CustomEvent('show-shortcuts'))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createDOM() {
|
||||||
|
this.overlay = document.createElement('div');
|
||||||
|
this.overlay.className = 'cmd-palette-overlay';
|
||||||
|
this.overlay.setAttribute('role', 'dialog');
|
||||||
|
this.overlay.setAttribute('aria-label', 'Command palette');
|
||||||
|
this.overlay.setAttribute('aria-modal', 'true');
|
||||||
|
|
||||||
|
this.overlay.innerHTML = `
|
||||||
|
<div class="cmd-palette">
|
||||||
|
<div class="cmd-palette-input-wrap">
|
||||||
|
<svg class="cmd-palette-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
<input type="text" class="cmd-palette-input" placeholder="Type a command..." aria-label="Search commands" autocomplete="off" spellcheck="false">
|
||||||
|
<kbd class="cmd-palette-hint">Esc</kbd>
|
||||||
|
</div>
|
||||||
|
<div class="cmd-palette-results" role="listbox" aria-label="Commands"></div>
|
||||||
|
<div class="cmd-palette-footer">
|
||||||
|
<span><kbd>Up</kbd><kbd>Down</kbd> navigate</span>
|
||||||
|
<span><kbd>Enter</kbd> execute</span>
|
||||||
|
<span><kbd>Esc</kbd> close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === this.overlay) this.hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.input = this.overlay.querySelector('.cmd-palette-input');
|
||||||
|
this.results = this.overlay.querySelector('.cmd-palette-results');
|
||||||
|
|
||||||
|
this.input.addEventListener('input', () => this.onInput());
|
||||||
|
this.input.addEventListener('keydown', (e) => this.onKeydown(e));
|
||||||
|
|
||||||
|
document.body.appendChild(this.overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindGlobalShortcut() {
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// Ctrl+K or Cmd+K
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.toggle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.visible ? this.hide() : this.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
show() {
|
||||||
|
this.visible = true;
|
||||||
|
this.overlay.classList.add('visible');
|
||||||
|
this.input.value = '';
|
||||||
|
this.selectedIndex = 0;
|
||||||
|
this.filteredCommands = [...this.commands];
|
||||||
|
this.renderResults();
|
||||||
|
this.input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
this.visible = false;
|
||||||
|
this.overlay.classList.remove('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
onInput() {
|
||||||
|
const query = this.input.value.toLowerCase().trim();
|
||||||
|
if (!query) {
|
||||||
|
this.filteredCommands = [...this.commands];
|
||||||
|
} else {
|
||||||
|
this.filteredCommands = this.commands
|
||||||
|
.map(cmd => {
|
||||||
|
const score = this.fuzzyScore(query, cmd);
|
||||||
|
return { ...cmd, score };
|
||||||
|
})
|
||||||
|
.filter(cmd => cmd.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
this.selectedIndex = 0;
|
||||||
|
this.renderResults();
|
||||||
|
}
|
||||||
|
|
||||||
|
fuzzyScore(query, cmd) {
|
||||||
|
const targets = [cmd.label.toLowerCase(), ...cmd.keywords, cmd.category.toLowerCase()];
|
||||||
|
let best = 0;
|
||||||
|
for (const target of targets) {
|
||||||
|
if (target === query) return 100;
|
||||||
|
if (target.startsWith(query)) best = Math.max(best, 80);
|
||||||
|
if (target.includes(query)) best = Math.max(best, 60);
|
||||||
|
// Check each word
|
||||||
|
const words = query.split(/\s+/);
|
||||||
|
const allMatch = words.every(w => targets.some(t => t.includes(w)));
|
||||||
|
if (allMatch) best = Math.max(best, 40);
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderResults() {
|
||||||
|
if (this.filteredCommands.length === 0) {
|
||||||
|
this.results.innerHTML = '<div class="cmd-palette-empty">No matching commands</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastCategory = '';
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
this.filteredCommands.forEach((cmd, i) => {
|
||||||
|
if (cmd.category !== lastCategory) {
|
||||||
|
lastCategory = cmd.category;
|
||||||
|
html += `<div class="cmd-palette-category">${cmd.category}</div>`;
|
||||||
|
}
|
||||||
|
const selected = i === this.selectedIndex ? ' cmd-palette-item-selected' : '';
|
||||||
|
html += `
|
||||||
|
<div class="cmd-palette-item${selected}" data-index="${i}" role="option" aria-selected="${i === this.selectedIndex}">
|
||||||
|
<span class="cmd-palette-item-icon">${this.getIcon(cmd.icon)}</span>
|
||||||
|
<span class="cmd-palette-item-label">${cmd.label}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.results.innerHTML = html;
|
||||||
|
|
||||||
|
// Click handlers
|
||||||
|
this.results.querySelectorAll('.cmd-palette-item').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
const idx = parseInt(el.dataset.index, 10);
|
||||||
|
this.executeCommand(idx);
|
||||||
|
});
|
||||||
|
el.addEventListener('mouseenter', () => {
|
||||||
|
this.selectedIndex = parseInt(el.dataset.index, 10);
|
||||||
|
this.updateSelection();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll selected into view
|
||||||
|
const selectedEl = this.results.querySelector('.cmd-palette-item-selected');
|
||||||
|
if (selectedEl) selectedEl.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelection() {
|
||||||
|
this.results.querySelectorAll('.cmd-palette-item').forEach((el, i) => {
|
||||||
|
const isSelected = parseInt(el.dataset.index, 10) === this.selectedIndex;
|
||||||
|
el.classList.toggle('cmd-palette-item-selected', isSelected);
|
||||||
|
el.setAttribute('aria-selected', String(isSelected));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeydown(e) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredCommands.length - 1);
|
||||||
|
this.updateSelection();
|
||||||
|
const sel = this.results.querySelector('.cmd-palette-item-selected');
|
||||||
|
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||||
|
this.updateSelection();
|
||||||
|
const sel = this.results.querySelector('.cmd-palette-item-selected');
|
||||||
|
if (sel) sel.scrollIntoView({ block: 'nearest' });
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.executeCommand(this.selectedIndex);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
executeCommand(index) {
|
||||||
|
const cmd = this.filteredCommands[index];
|
||||||
|
if (cmd) {
|
||||||
|
this.hide();
|
||||||
|
cmd.action();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getIcon(name) {
|
||||||
|
const icons = {
|
||||||
|
grid: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>',
|
||||||
|
cpu: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/></svg>',
|
||||||
|
play: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
|
||||||
|
layers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>',
|
||||||
|
zap: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
|
||||||
|
box: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>',
|
||||||
|
wifi: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12.55a11 11 0 0 1 14.08 0"/><path d="M1.42 9a16 16 0 0 1 21.16 0"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/></svg>',
|
||||||
|
database: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>',
|
||||||
|
external: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>',
|
||||||
|
moon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>',
|
||||||
|
activity: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>',
|
||||||
|
list: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>',
|
||||||
|
download: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
|
||||||
|
maximize: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>',
|
||||||
|
keyboard: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><line x1="6" y1="8" x2="6.01" y2="8"/><line x1="10" y1="8" x2="10.01" y2="8"/><line x1="14" y1="8" x2="14.01" y2="8"/><line x1="18" y1="8" x2="18.01" y2="8"/><line x1="8" y1="12" x2="8.01" y2="12"/><line x1="12" y1="12" x2="12.01" y2="12"/><line x1="16" y1="12" x2="16.01" y2="12"/><line x1="7" y1="16" x2="17" y2="16"/></svg>'
|
||||||
|
};
|
||||||
|
return icons[name] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.overlay?.parentNode) {
|
||||||
|
this.overlay.parentNode.removeChild(this.overlay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Connection Status Widget - Persistent indicator in header
|
||||||
|
// Shows WebSocket and API connection state with reconnect button
|
||||||
|
|
||||||
|
import { sensingService } from '../services/sensing.service.js';
|
||||||
|
|
||||||
|
export class ConnectionStatus {
|
||||||
|
constructor() {
|
||||||
|
this.widget = null;
|
||||||
|
this._unsub = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createWidget();
|
||||||
|
this.subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
createWidget() {
|
||||||
|
this.widget = document.createElement('div');
|
||||||
|
this.widget.className = 'conn-status';
|
||||||
|
this.widget.setAttribute('role', 'status');
|
||||||
|
this.widget.setAttribute('aria-live', 'polite');
|
||||||
|
this.widget.innerHTML = `
|
||||||
|
<span class="conn-status-dot"></span>
|
||||||
|
<span class="conn-status-label">Connecting</span>
|
||||||
|
<button class="conn-status-reconnect" aria-label="Reconnect" title="Reconnect" style="display:none">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.widget.querySelector('.conn-status-reconnect').addEventListener('click', () => {
|
||||||
|
this.setStatus('reconnecting', 'Reconnecting...');
|
||||||
|
sensingService.reconnect?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Insert into header-info, after theme toggle if present
|
||||||
|
const headerInfo = document.querySelector('.header-info');
|
||||||
|
if (headerInfo) {
|
||||||
|
headerInfo.prepend(this.widget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe() {
|
||||||
|
this._unsub = sensingService.onStateChange(() => {
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
// Initial
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
const state = sensingService.state;
|
||||||
|
const source = sensingService.dataSource;
|
||||||
|
|
||||||
|
if (state === 'connected' || state === 'streaming') {
|
||||||
|
const label = source === 'live' ? 'Live' :
|
||||||
|
source === 'server-simulated' ? 'Simulated' :
|
||||||
|
'Connected';
|
||||||
|
this.setStatus('connected', label);
|
||||||
|
} else if (state === 'connecting' || state === 'reconnecting') {
|
||||||
|
this.setStatus('reconnecting', 'Connecting...');
|
||||||
|
} else if (state === 'error') {
|
||||||
|
this.setStatus('error', 'Error');
|
||||||
|
} else {
|
||||||
|
this.setStatus('disconnected', 'Offline');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(status, label) {
|
||||||
|
if (!this.widget) return;
|
||||||
|
this.widget.className = `conn-status conn-status-${status}`;
|
||||||
|
this.widget.querySelector('.conn-status-label').textContent = label;
|
||||||
|
|
||||||
|
const reconnectBtn = this.widget.querySelector('.conn-status-reconnect');
|
||||||
|
reconnectBtn.style.display =
|
||||||
|
(status === 'disconnected' || status === 'error') ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this._unsub) this._unsub();
|
||||||
|
if (this.widget?.parentNode) {
|
||||||
|
this.widget.parentNode.removeChild(this.widget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Data Export Utility - Export sensor/pose data as JSON or CSV
|
||||||
|
|
||||||
|
import { sensingService } from '../services/sensing.service.js';
|
||||||
|
import { toastManager } from './toast.js';
|
||||||
|
|
||||||
|
export class DataExport {
|
||||||
|
constructor() {
|
||||||
|
this.buffer = [];
|
||||||
|
this.maxBuffer = 1000;
|
||||||
|
this.recording = false;
|
||||||
|
this._unsub = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
document.addEventListener('export-data', () => this.showExportDialog());
|
||||||
|
|
||||||
|
// Continuously buffer sensing data when available
|
||||||
|
this._unsub = sensingService.onData((data) => {
|
||||||
|
if (this.buffer.length >= this.maxBuffer) {
|
||||||
|
this.buffer.shift();
|
||||||
|
}
|
||||||
|
this.buffer.push({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...this.extractFields(data)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
extractFields(data) {
|
||||||
|
// Extract relevant fields from sensing data
|
||||||
|
return {
|
||||||
|
rssi: data.rssi ?? null,
|
||||||
|
variance: data.variance ?? null,
|
||||||
|
motion_band: data.motion_band ?? null,
|
||||||
|
breathing_band: data.breathing_band ?? null,
|
||||||
|
classification: data.classification ?? null,
|
||||||
|
person_count: data.person_count ?? data.persons ?? null,
|
||||||
|
subcarriers: data.subcarrier_count ?? null,
|
||||||
|
source: data.source ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
showExportDialog() {
|
||||||
|
if (this.buffer.length === 0) {
|
||||||
|
toastManager.warning('No sensor data to export. Connect to a data source first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create dialog
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'export-dialog-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="export-dialog" role="dialog" aria-label="Export data" aria-modal="true">
|
||||||
|
<h3>Export Sensor Data</h3>
|
||||||
|
<p class="export-dialog-info">${this.buffer.length} data points available</p>
|
||||||
|
<div class="export-dialog-options">
|
||||||
|
<label class="export-option">
|
||||||
|
<input type="radio" name="export-format" value="json" checked>
|
||||||
|
<span>JSON</span>
|
||||||
|
<small>Full data with nested fields</small>
|
||||||
|
</label>
|
||||||
|
<label class="export-option">
|
||||||
|
<input type="radio" name="export-format" value="csv">
|
||||||
|
<span>CSV</span>
|
||||||
|
<small>Flat table, spreadsheet-ready</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="export-dialog-range">
|
||||||
|
<label>
|
||||||
|
Last <input type="number" id="export-count" value="${Math.min(this.buffer.length, 500)}" min="1" max="${this.buffer.length}"> data points
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="export-dialog-actions">
|
||||||
|
<button class="btn btn--secondary export-cancel">Cancel</button>
|
||||||
|
<button class="btn btn--primary export-confirm">Export</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) overlay.remove();
|
||||||
|
});
|
||||||
|
overlay.querySelector('.export-cancel').addEventListener('click', () => overlay.remove());
|
||||||
|
overlay.querySelector('.export-confirm').addEventListener('click', () => {
|
||||||
|
const format = overlay.querySelector('input[name="export-format"]:checked').value;
|
||||||
|
const count = parseInt(overlay.querySelector('#export-count').value, 10) || this.buffer.length;
|
||||||
|
this.exportData(format, count);
|
||||||
|
overlay.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay.querySelector('.export-confirm').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
exportData(format, count) {
|
||||||
|
const data = this.buffer.slice(-count);
|
||||||
|
|
||||||
|
let content, filename, mimeType;
|
||||||
|
|
||||||
|
if (format === 'json') {
|
||||||
|
content = JSON.stringify(data, null, 2);
|
||||||
|
filename = `ruview-data-${this.timestamp()}.json`;
|
||||||
|
mimeType = 'application/json';
|
||||||
|
} else {
|
||||||
|
content = this.toCSV(data);
|
||||||
|
filename = `ruview-data-${this.timestamp()}.csv`;
|
||||||
|
mimeType = 'text/csv';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.downloadFile(content, filename, mimeType);
|
||||||
|
toastManager.success(`Exported ${data.length} data points as ${format.toUpperCase()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
toCSV(data) {
|
||||||
|
if (data.length === 0) return '';
|
||||||
|
const headers = Object.keys(data[0]);
|
||||||
|
const rows = data.map(row => headers.map(h => {
|
||||||
|
const val = row[h];
|
||||||
|
if (val === null || val === undefined) return '';
|
||||||
|
if (typeof val === 'string' && (val.includes(',') || val.includes('"'))) {
|
||||||
|
return `"${val.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return String(val);
|
||||||
|
}).join(','));
|
||||||
|
return [headers.join(','), ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFile(content, filename, mimeType) {
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.style.display = 'none';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp() {
|
||||||
|
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this._unsub) this._unsub();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Fullscreen Mode - Toggle fullscreen on visualization tabs
|
||||||
|
// Activated via F11 key, command palette, or button
|
||||||
|
|
||||||
|
export class FullscreenManager {
|
||||||
|
constructor() {
|
||||||
|
this.isFullscreen = false;
|
||||||
|
this.targetElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
document.addEventListener('toggle-fullscreen', () => this.toggle());
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'F11') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.toggle();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
this.isFullscreen = !!document.fullscreenElement;
|
||||||
|
this.updateUI();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if (this.isFullscreen) {
|
||||||
|
this.exit();
|
||||||
|
} else {
|
||||||
|
this.enter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enter() {
|
||||||
|
// Find the active tab content
|
||||||
|
const activePanel = document.querySelector('.tab-content.active');
|
||||||
|
if (!activePanel) return;
|
||||||
|
|
||||||
|
this.targetElement = activePanel;
|
||||||
|
|
||||||
|
if (activePanel.requestFullscreen) {
|
||||||
|
activePanel.requestFullscreen();
|
||||||
|
} else if (activePanel.webkitRequestFullscreen) {
|
||||||
|
activePanel.webkitRequestFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exit() {
|
||||||
|
if (document.exitFullscreen) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else if (document.webkitExitFullscreen) {
|
||||||
|
document.webkitExitFullscreen();
|
||||||
|
}
|
||||||
|
this.targetElement = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUI() {
|
||||||
|
document.body.classList.toggle('is-fullscreen', this.isFullscreen);
|
||||||
|
|
||||||
|
// Add/remove exit button when in fullscreen
|
||||||
|
let exitBtn = document.getElementById('fullscreen-exit-btn');
|
||||||
|
if (this.isFullscreen && !exitBtn) {
|
||||||
|
exitBtn = document.createElement('button');
|
||||||
|
exitBtn.id = 'fullscreen-exit-btn';
|
||||||
|
exitBtn.className = 'fullscreen-exit-btn';
|
||||||
|
exitBtn.setAttribute('aria-label', 'Exit fullscreen');
|
||||||
|
exitBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>';
|
||||||
|
exitBtn.title = 'Exit fullscreen (F11)';
|
||||||
|
exitBtn.addEventListener('click', () => this.exit());
|
||||||
|
document.body.appendChild(exitBtn);
|
||||||
|
} else if (!this.isFullscreen && exitBtn) {
|
||||||
|
exitBtn.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.isFullscreen) this.exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
// Internationalization - EN/PL language support
|
||||||
|
// Detects browser language, persists choice, translates UI strings
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
en: {
|
||||||
|
// Navigation
|
||||||
|
'nav.dashboard': 'Dashboard',
|
||||||
|
'nav.hardware': 'Hardware',
|
||||||
|
'nav.demo': 'Live Demo',
|
||||||
|
'nav.architecture': 'Architecture',
|
||||||
|
'nav.performance': 'Performance',
|
||||||
|
'nav.applications': 'Applications',
|
||||||
|
'nav.sensing': 'Sensing',
|
||||||
|
'nav.training': 'Training',
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
'dashboard.title': 'Revolutionary WiFi-Based Human Pose Detection',
|
||||||
|
'dashboard.subtitle': 'Human Tracking Through Walls Using WiFi Signals',
|
||||||
|
'dashboard.description': 'AI can track your full-body movement through walls using just WiFi signals. Researchers at Carnegie Mellon have trained a neural network to turn basic WiFi signals into detailed wireframe models of human bodies.',
|
||||||
|
'dashboard.status': 'System Status',
|
||||||
|
'dashboard.metrics': 'System Metrics',
|
||||||
|
'dashboard.features': 'Features',
|
||||||
|
'dashboard.liveStats': 'Live Statistics',
|
||||||
|
'dashboard.activePersons': 'Active Persons',
|
||||||
|
'dashboard.avgConfidence': 'Avg Confidence',
|
||||||
|
'dashboard.totalDetections': 'Total Detections',
|
||||||
|
'dashboard.zoneOccupancy': 'Zone Occupancy',
|
||||||
|
|
||||||
|
// Status
|
||||||
|
'status.apiServer': 'API Server',
|
||||||
|
'status.hardware': 'Hardware',
|
||||||
|
'status.inference': 'Inference',
|
||||||
|
'status.streaming': 'Streaming',
|
||||||
|
'status.dataSource': 'Data Source',
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
'metrics.cpu': 'CPU Usage',
|
||||||
|
'metrics.memory': 'Memory Usage',
|
||||||
|
'metrics.disk': 'Disk Usage',
|
||||||
|
|
||||||
|
// Benefits
|
||||||
|
'benefit.throughWalls': 'Through Walls',
|
||||||
|
'benefit.throughWallsDesc': 'Works through solid barriers with no line of sight required',
|
||||||
|
'benefit.privacy': 'Privacy-Preserving',
|
||||||
|
'benefit.privacyDesc': 'No cameras or visual recording - just WiFi signal analysis',
|
||||||
|
'benefit.realtime': 'Real-Time',
|
||||||
|
'benefit.realtimeDesc': 'Maps 24 body regions in real-time at 100Hz sampling rate',
|
||||||
|
'benefit.lowCost': 'Low Cost',
|
||||||
|
'benefit.lowCostDesc': 'Built using $30 commercial WiFi hardware',
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
'stat.bodyRegions': 'Body Regions',
|
||||||
|
'stat.samplingRate': 'Sampling Rate',
|
||||||
|
'stat.accuracy': 'Accuracy (AP@50)',
|
||||||
|
'stat.hardwareCost': 'Hardware Cost',
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
'action.startDetection': 'Start Detection',
|
||||||
|
'action.stopDetection': 'Stop Detection',
|
||||||
|
'action.toggleTheme': 'Toggle theme',
|
||||||
|
'action.exportData': 'Export data',
|
||||||
|
'action.screenshot': 'Take screenshot',
|
||||||
|
|
||||||
|
// Connection
|
||||||
|
'conn.connected': 'Connected',
|
||||||
|
'conn.connecting': 'Connecting...',
|
||||||
|
'conn.offline': 'Offline',
|
||||||
|
'conn.reconnecting': 'Reconnecting...',
|
||||||
|
'conn.live': 'Live',
|
||||||
|
'conn.simulated': 'Simulated',
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
'misc.loading': 'Loading...',
|
||||||
|
'misc.error': 'An error occurred',
|
||||||
|
'misc.noData': 'No data available',
|
||||||
|
'misc.close': 'Close',
|
||||||
|
'misc.cancel': 'Cancel',
|
||||||
|
'misc.confirm': 'Confirm',
|
||||||
|
'misc.settings': 'Settings',
|
||||||
|
'misc.language': 'Language'
|
||||||
|
},
|
||||||
|
|
||||||
|
pl: {
|
||||||
|
// Navigation
|
||||||
|
'nav.dashboard': 'Panel',
|
||||||
|
'nav.hardware': 'Sprzet',
|
||||||
|
'nav.demo': 'Demo na zywo',
|
||||||
|
'nav.architecture': 'Architektura',
|
||||||
|
'nav.performance': 'Wydajnosc',
|
||||||
|
'nav.applications': 'Aplikacje',
|
||||||
|
'nav.sensing': 'Czujniki',
|
||||||
|
'nav.training': 'Trening',
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
'dashboard.title': 'Rewolucyjne wykrywanie pozy czlowieka przez WiFi',
|
||||||
|
'dashboard.subtitle': 'Sledzenie ludzi przez sciany za pomoca sygnalow WiFi',
|
||||||
|
'dashboard.description': 'AI moze sledzic ruchy calego ciala przez sciany uzywajac jedynie sygnalow WiFi. Badacze z Carnegie Mellon wytrenowali siec neuronowa do zamiany sygnalow WiFi w szczegolowe modele szkieletowe.',
|
||||||
|
'dashboard.status': 'Status systemu',
|
||||||
|
'dashboard.metrics': 'Metryki systemu',
|
||||||
|
'dashboard.features': 'Funkcje',
|
||||||
|
'dashboard.liveStats': 'Statystyki na zywo',
|
||||||
|
'dashboard.activePersons': 'Aktywne osoby',
|
||||||
|
'dashboard.avgConfidence': 'Srednia pewnosc',
|
||||||
|
'dashboard.totalDetections': 'Laczne detekcje',
|
||||||
|
'dashboard.zoneOccupancy': 'Zajecie stref',
|
||||||
|
|
||||||
|
// Status
|
||||||
|
'status.apiServer': 'Serwer API',
|
||||||
|
'status.hardware': 'Sprzet',
|
||||||
|
'status.inference': 'Wnioskowanie',
|
||||||
|
'status.streaming': 'Streaming',
|
||||||
|
'status.dataSource': 'Zrodlo danych',
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
'metrics.cpu': 'Uzycie CPU',
|
||||||
|
'metrics.memory': 'Uzycie pamieci',
|
||||||
|
'metrics.disk': 'Uzycie dysku',
|
||||||
|
|
||||||
|
// Benefits
|
||||||
|
'benefit.throughWalls': 'Przez sciany',
|
||||||
|
'benefit.throughWallsDesc': 'Dziala przez przeszkody stale bez linii wzroku',
|
||||||
|
'benefit.privacy': 'Ochrona prywatnosci',
|
||||||
|
'benefit.privacyDesc': 'Brak kamer i nagrywania - tylko analiza sygnalow WiFi',
|
||||||
|
'benefit.realtime': 'Czas rzeczywisty',
|
||||||
|
'benefit.realtimeDesc': 'Mapuje 24 regiony ciala w czasie rzeczywistym przy 100Hz',
|
||||||
|
'benefit.lowCost': 'Niski koszt',
|
||||||
|
'benefit.lowCostDesc': 'Zbudowany z komercyjnego sprzetu WiFi za $30',
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
'stat.bodyRegions': 'Regiony ciala',
|
||||||
|
'stat.samplingRate': 'Czestotliwosc',
|
||||||
|
'stat.accuracy': 'Dokladnosc (AP@50)',
|
||||||
|
'stat.hardwareCost': 'Koszt sprzetu',
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
'action.startDetection': 'Rozpocznij detekcje',
|
||||||
|
'action.stopDetection': 'Zatrzymaj detekcje',
|
||||||
|
'action.toggleTheme': 'Zmien motyw',
|
||||||
|
'action.exportData': 'Eksportuj dane',
|
||||||
|
'action.screenshot': 'Zrob zrzut ekranu',
|
||||||
|
|
||||||
|
// Connection
|
||||||
|
'conn.connected': 'Polaczono',
|
||||||
|
'conn.connecting': 'Laczenie...',
|
||||||
|
'conn.offline': 'Offline',
|
||||||
|
'conn.reconnecting': 'Ponowne laczenie...',
|
||||||
|
'conn.live': 'Na zywo',
|
||||||
|
'conn.simulated': 'Symulacja',
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
'misc.loading': 'Ladowanie...',
|
||||||
|
'misc.error': 'Wystapil blad',
|
||||||
|
'misc.noData': 'Brak danych',
|
||||||
|
'misc.close': 'Zamknij',
|
||||||
|
'misc.cancel': 'Anuluj',
|
||||||
|
'misc.confirm': 'Potwierdz',
|
||||||
|
'misc.settings': 'Ustawienia',
|
||||||
|
'misc.language': 'Jezyk'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class I18n {
|
||||||
|
constructor() {
|
||||||
|
this.locale = this.getSavedLocale() || this.detectLocale();
|
||||||
|
this.listeners = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createSelector();
|
||||||
|
this.applyTranslations();
|
||||||
|
}
|
||||||
|
|
||||||
|
detectLocale() {
|
||||||
|
const lang = navigator.language?.toLowerCase() || 'en';
|
||||||
|
if (lang.startsWith('pl')) return 'pl';
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
getSavedLocale() {
|
||||||
|
try { return localStorage.getItem('ruview-locale'); }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
saveLocale(locale) {
|
||||||
|
try { localStorage.setItem('ruview-locale', locale); }
|
||||||
|
catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
t(key) {
|
||||||
|
const dict = translations[this.locale] || translations.en;
|
||||||
|
return dict[key] || translations.en[key] || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLocale(locale) {
|
||||||
|
if (!translations[locale]) return;
|
||||||
|
this.locale = locale;
|
||||||
|
this.saveLocale(locale);
|
||||||
|
document.documentElement.setAttribute('lang', locale);
|
||||||
|
this.applyTranslations();
|
||||||
|
this.listeners.forEach(cb => { try { cb(locale); } catch { /* noop */ } });
|
||||||
|
}
|
||||||
|
|
||||||
|
onLocaleChange(callback) {
|
||||||
|
this.listeners.push(callback);
|
||||||
|
return () => {
|
||||||
|
const i = this.listeners.indexOf(callback);
|
||||||
|
if (i > -1) this.listeners.splice(i, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTranslations() {
|
||||||
|
// Translate elements with data-i18n attribute
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||||
|
const key = el.getAttribute('data-i18n');
|
||||||
|
el.textContent = this.t(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Translate placeholders
|
||||||
|
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||||
|
const key = el.getAttribute('data-i18n-placeholder');
|
||||||
|
el.placeholder = this.t(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Translate aria-labels
|
||||||
|
document.querySelectorAll('[data-i18n-aria]').forEach(el => {
|
||||||
|
const key = el.getAttribute('data-i18n-aria');
|
||||||
|
el.setAttribute('aria-label', this.t(key));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update language selector
|
||||||
|
const selector = document.getElementById('lang-selector');
|
||||||
|
if (selector) selector.value = this.locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
createSelector() {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'lang-selector-wrap';
|
||||||
|
wrapper.innerHTML = `
|
||||||
|
<select id="lang-selector" class="lang-selector" aria-label="Language">
|
||||||
|
<option value="en">EN</option>
|
||||||
|
<option value="pl">PL</option>
|
||||||
|
</select>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const select = wrapper.querySelector('select');
|
||||||
|
select.value = this.locale;
|
||||||
|
select.addEventListener('change', () => this.setLocale(select.value));
|
||||||
|
|
||||||
|
const headerInfo = document.querySelector('.header-info');
|
||||||
|
if (headerInfo) {
|
||||||
|
headerInfo.appendChild(wrapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAvailableLocales() {
|
||||||
|
return Object.keys(translations);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.listeners = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const i18n = new I18n();
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Idle Manager - Pauses animations, polling, and WebSocket pings when user is inactive
|
||||||
|
// Reduces CPU/battery usage on idle dashboards
|
||||||
|
|
||||||
|
export class IdleManager {
|
||||||
|
constructor() {
|
||||||
|
this.idleTimeout = 3 * 60 * 1000; // 3 minutes
|
||||||
|
this.isIdle = false;
|
||||||
|
this.timer = null;
|
||||||
|
this.callbacks = { idle: [], active: [] };
|
||||||
|
this.events = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll'];
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.resetTimer();
|
||||||
|
this.events.forEach(evt => {
|
||||||
|
document.addEventListener(evt, () => this.onActivity(), { passive: true, capture: true });
|
||||||
|
});
|
||||||
|
// Also use Page Visibility API
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
this.goIdle();
|
||||||
|
} else {
|
||||||
|
this.goActive();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onActivity() {
|
||||||
|
if (this.isIdle) {
|
||||||
|
this.goActive();
|
||||||
|
}
|
||||||
|
this.resetTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTimer() {
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(() => this.goIdle(), this.idleTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
goIdle() {
|
||||||
|
if (this.isIdle) return;
|
||||||
|
this.isIdle = true;
|
||||||
|
console.info('[Idle] User inactive - pausing background tasks');
|
||||||
|
this.notify('idle');
|
||||||
|
document.body.classList.add('user-idle');
|
||||||
|
}
|
||||||
|
|
||||||
|
goActive() {
|
||||||
|
if (!this.isIdle) return;
|
||||||
|
this.isIdle = false;
|
||||||
|
console.info('[Idle] User active - resuming background tasks');
|
||||||
|
this.notify('active');
|
||||||
|
document.body.classList.remove('user-idle');
|
||||||
|
this.resetTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
onIdle(callback) {
|
||||||
|
this.callbacks.idle.push(callback);
|
||||||
|
return () => {
|
||||||
|
const i = this.callbacks.idle.indexOf(callback);
|
||||||
|
if (i > -1) this.callbacks.idle.splice(i, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onActive(callback) {
|
||||||
|
this.callbacks.active.push(callback);
|
||||||
|
return () => {
|
||||||
|
const i = this.callbacks.active.indexOf(callback);
|
||||||
|
if (i > -1) this.callbacks.active.splice(i, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(type) {
|
||||||
|
this.callbacks[type].forEach(cb => {
|
||||||
|
try { cb(); } catch (e) { console.error('[Idle] Callback error:', e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.callbacks = { idle: [], active: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Keyboard Shortcuts System
|
||||||
|
// Press '?' to show help overlay, number keys to switch tabs, etc.
|
||||||
|
|
||||||
|
export class KeyboardShortcuts {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app;
|
||||||
|
this.shortcuts = new Map();
|
||||||
|
this.helpVisible = false;
|
||||||
|
this.enabled = true;
|
||||||
|
this.overlay = null;
|
||||||
|
this.registerDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
registerDefaults() {
|
||||||
|
this.register('?', 'Show keyboard shortcuts', () => this.toggleHelp());
|
||||||
|
this.register('Escape', 'Close overlay / dialog', () => this.closeAll());
|
||||||
|
this.register('1', 'Switch to Dashboard tab', () => this.switchTab('dashboard'));
|
||||||
|
this.register('2', 'Switch to Hardware tab', () => this.switchTab('hardware'));
|
||||||
|
this.register('3', 'Switch to Live Demo tab', () => this.switchTab('demo'));
|
||||||
|
this.register('4', 'Switch to Architecture tab', () => this.switchTab('architecture'));
|
||||||
|
this.register('5', 'Switch to Performance tab', () => this.switchTab('performance'));
|
||||||
|
this.register('6', 'Switch to Applications tab', () => this.switchTab('applications'));
|
||||||
|
this.register('7', 'Switch to Sensing tab', () => this.switchTab('sensing'));
|
||||||
|
this.register('8', 'Switch to Training tab', () => this.switchTab('training'));
|
||||||
|
this.register('p', 'Toggle performance monitor', () => this.togglePerfMonitor());
|
||||||
|
this.register('t', 'Toggle dark/light theme', () => this.toggleTheme());
|
||||||
|
}
|
||||||
|
|
||||||
|
register(key, description, handler) {
|
||||||
|
this.shortcuts.set(key, { description, handler });
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
document.addEventListener('keydown', (e) => this.handleKeydown(e));
|
||||||
|
this.createOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeydown(e) {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
|
||||||
|
// Ignore when typing in inputs
|
||||||
|
const tag = e.target.tagName.toLowerCase();
|
||||||
|
if (tag === 'input' || tag === 'textarea' || tag === 'select' || e.target.isContentEditable) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.target.blur();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore modified keys (except shift for '?')
|
||||||
|
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||||
|
|
||||||
|
const shortcut = this.shortcuts.get(e.key);
|
||||||
|
if (shortcut) {
|
||||||
|
e.preventDefault();
|
||||||
|
shortcut.handler();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switchTab(tabId) {
|
||||||
|
const tabManager = this.app?.getComponent?.('tabManager');
|
||||||
|
if (tabManager) {
|
||||||
|
tabManager.switchToTab(tabId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePerfMonitor() {
|
||||||
|
const event = new CustomEvent('toggle-perf-monitor');
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTheme() {
|
||||||
|
const event = new CustomEvent('toggle-theme');
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAll() {
|
||||||
|
if (this.helpVisible) {
|
||||||
|
this.hideHelp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createOverlay() {
|
||||||
|
this.overlay = document.createElement('div');
|
||||||
|
this.overlay.className = 'shortcuts-overlay';
|
||||||
|
this.overlay.setAttribute('role', 'dialog');
|
||||||
|
this.overlay.setAttribute('aria-label', 'Keyboard shortcuts');
|
||||||
|
this.overlay.setAttribute('aria-modal', 'true');
|
||||||
|
this.overlay.innerHTML = this.buildHelpHTML();
|
||||||
|
this.overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === this.overlay) this.hideHelp();
|
||||||
|
});
|
||||||
|
document.body.appendChild(this.overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildHelpHTML() {
|
||||||
|
const groups = [
|
||||||
|
{
|
||||||
|
title: 'Navigation',
|
||||||
|
items: Array.from(this.shortcuts.entries())
|
||||||
|
.filter(([key]) => /^[1-8]$/.test(key))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Actions',
|
||||||
|
items: Array.from(this.shortcuts.entries())
|
||||||
|
.filter(([key]) => /^[a-z]$/.test(key))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'General',
|
||||||
|
items: Array.from(this.shortcuts.entries())
|
||||||
|
.filter(([key]) => !/^[1-8a-z]$/.test(key))
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="shortcuts-panel">
|
||||||
|
<div class="shortcuts-header">
|
||||||
|
<h2>Keyboard Shortcuts</h2>
|
||||||
|
<button class="shortcuts-close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="shortcuts-body">
|
||||||
|
${groups.map(group => `
|
||||||
|
<div class="shortcuts-group">
|
||||||
|
<h3>${group.title}</h3>
|
||||||
|
${group.items.map(([key, { description }]) => `
|
||||||
|
<div class="shortcut-row">
|
||||||
|
<kbd>${this.formatKey(key)}</kbd>
|
||||||
|
<span>${description}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatKey(key) {
|
||||||
|
const map = { Escape: 'Esc', '?': '?' };
|
||||||
|
return map[key] || key.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleHelp() {
|
||||||
|
this.helpVisible ? this.hideHelp() : this.showHelp();
|
||||||
|
}
|
||||||
|
|
||||||
|
showHelp() {
|
||||||
|
this.overlay.classList.add('visible');
|
||||||
|
this.helpVisible = true;
|
||||||
|
// Focus close button
|
||||||
|
const closeBtn = this.overlay.querySelector('.shortcuts-close');
|
||||||
|
if (closeBtn) {
|
||||||
|
closeBtn.onclick = () => this.hideHelp();
|
||||||
|
closeBtn.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hideHelp() {
|
||||||
|
this.overlay.classList.remove('visible');
|
||||||
|
this.helpVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.overlay?.parentNode) {
|
||||||
|
this.overlay.parentNode.removeChild(this.overlay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
// Mobile Navigation - Hamburger menu for small screens
|
||||||
|
// Replaces wrapped tab bar with a slide-out drawer on mobile
|
||||||
|
|
||||||
|
export class MobileNav {
|
||||||
|
constructor() {
|
||||||
|
this.drawer = null;
|
||||||
|
this.backdrop = null;
|
||||||
|
this.hamburger = null;
|
||||||
|
this.isOpen = false;
|
||||||
|
this.mql = window.matchMedia('(max-width: 768px)');
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createHamburger();
|
||||||
|
this.createDrawer();
|
||||||
|
this.bindEvents();
|
||||||
|
this.onMediaChange(this.mql);
|
||||||
|
}
|
||||||
|
|
||||||
|
createHamburger() {
|
||||||
|
this.hamburger = document.createElement('button');
|
||||||
|
this.hamburger.className = 'mobile-hamburger';
|
||||||
|
this.hamburger.setAttribute('aria-label', 'Open navigation menu');
|
||||||
|
this.hamburger.setAttribute('aria-expanded', 'false');
|
||||||
|
this.hamburger.innerHTML = `
|
||||||
|
<span class="hamburger-line"></span>
|
||||||
|
<span class="hamburger-line"></span>
|
||||||
|
<span class="hamburger-line"></span>
|
||||||
|
`;
|
||||||
|
this.hamburger.addEventListener('click', () => this.toggle());
|
||||||
|
|
||||||
|
const header = document.querySelector('.header');
|
||||||
|
if (header) {
|
||||||
|
header.style.position = 'relative';
|
||||||
|
header.appendChild(this.hamburger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createDrawer() {
|
||||||
|
// Backdrop
|
||||||
|
this.backdrop = document.createElement('div');
|
||||||
|
this.backdrop.className = 'mobile-nav-backdrop';
|
||||||
|
this.backdrop.addEventListener('click', () => this.close());
|
||||||
|
document.body.appendChild(this.backdrop);
|
||||||
|
|
||||||
|
// Drawer
|
||||||
|
this.drawer = document.createElement('nav');
|
||||||
|
this.drawer.className = 'mobile-nav-drawer';
|
||||||
|
this.drawer.setAttribute('role', 'navigation');
|
||||||
|
this.drawer.setAttribute('aria-label', 'Mobile navigation');
|
||||||
|
|
||||||
|
// Clone tabs into drawer
|
||||||
|
const tabs = document.querySelectorAll('.nav-tabs .nav-tab');
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'mobile-nav-list';
|
||||||
|
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
const item = document.createElement(tab.tagName === 'A' ? 'a' : 'button');
|
||||||
|
item.className = 'mobile-nav-item';
|
||||||
|
item.textContent = tab.textContent.trim();
|
||||||
|
|
||||||
|
if (tab.tagName === 'A') {
|
||||||
|
item.href = tab.href;
|
||||||
|
} else {
|
||||||
|
const tabId = tab.getAttribute('data-tab');
|
||||||
|
item.dataset.tab = tabId;
|
||||||
|
if (tab.classList.contains('active')) {
|
||||||
|
item.classList.add('active');
|
||||||
|
}
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
// Activate tab via the original tab manager
|
||||||
|
tab.click();
|
||||||
|
this.close();
|
||||||
|
// Update active states in drawer
|
||||||
|
list.querySelectorAll('.mobile-nav-item').forEach(i => i.classList.remove('active'));
|
||||||
|
item.classList.add('active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.drawer.appendChild(list);
|
||||||
|
|
||||||
|
// Keyboard hint at bottom
|
||||||
|
const hint = document.createElement('div');
|
||||||
|
hint.className = 'mobile-nav-hint';
|
||||||
|
hint.textContent = 'Tip: Press Ctrl+K for command palette';
|
||||||
|
this.drawer.appendChild(hint);
|
||||||
|
|
||||||
|
document.body.appendChild(this.drawer);
|
||||||
|
|
||||||
|
// Sync active tab when tabs change externally
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
const activeTab = document.querySelector('.nav-tabs .nav-tab.active');
|
||||||
|
if (activeTab) {
|
||||||
|
const activeId = activeTab.getAttribute('data-tab');
|
||||||
|
list.querySelectorAll('.mobile-nav-item').forEach(item => {
|
||||||
|
item.classList.toggle('active', item.dataset.tab === activeId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const navTabs = document.querySelector('.nav-tabs');
|
||||||
|
if (navTabs) {
|
||||||
|
observer.observe(navTabs, { attributes: true, subtree: true, attributeFilter: ['class'] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Listen for media query changes
|
||||||
|
this.mql.addEventListener('change', (e) => this.onMediaChange(e));
|
||||||
|
|
||||||
|
// Close on escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && this.isOpen) this.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Swipe to close
|
||||||
|
let touchStartX = 0;
|
||||||
|
this.drawer.addEventListener('touchstart', (e) => {
|
||||||
|
touchStartX = e.touches[0].clientX;
|
||||||
|
}, { passive: true });
|
||||||
|
this.drawer.addEventListener('touchend', (e) => {
|
||||||
|
const deltaX = e.changedTouches[0].clientX - touchStartX;
|
||||||
|
if (deltaX < -50) this.close(); // Swipe left to close
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
onMediaChange(mql) {
|
||||||
|
const isMobile = mql.matches !== undefined ? mql.matches : mql;
|
||||||
|
document.body.classList.toggle('mobile-nav-active', isMobile);
|
||||||
|
|
||||||
|
if (!isMobile && this.isOpen) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.isOpen ? this.close() : this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.isOpen = true;
|
||||||
|
this.drawer.classList.add('open');
|
||||||
|
this.backdrop.classList.add('open');
|
||||||
|
this.hamburger.classList.add('open');
|
||||||
|
this.hamburger.setAttribute('aria-expanded', 'true');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
// Focus first item
|
||||||
|
const first = this.drawer.querySelector('.mobile-nav-item');
|
||||||
|
if (first) first.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.isOpen = false;
|
||||||
|
this.drawer.classList.remove('open');
|
||||||
|
this.backdrop.classList.remove('open');
|
||||||
|
this.hamburger.classList.remove('open');
|
||||||
|
this.hamburger.setAttribute('aria-expanded', 'false');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.close();
|
||||||
|
this.hamburger?.remove();
|
||||||
|
this.drawer?.remove();
|
||||||
|
this.backdrop?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
// Notification Center - Bell icon with event history
|
||||||
|
// Persists notifications across page views (sessionStorage)
|
||||||
|
|
||||||
|
export class NotificationCenter {
|
||||||
|
constructor() {
|
||||||
|
this.button = null;
|
||||||
|
this.panel = null;
|
||||||
|
this.notifications = [];
|
||||||
|
this.maxNotifications = 50;
|
||||||
|
this.isOpen = false;
|
||||||
|
this.unreadCount = 0;
|
||||||
|
this.storageKey = 'ruview-notifications';
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadFromStorage();
|
||||||
|
this.createButton();
|
||||||
|
this.createPanel();
|
||||||
|
this.interceptEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
createButton() {
|
||||||
|
this.button = document.createElement('button');
|
||||||
|
this.button.className = 'notif-bell';
|
||||||
|
this.button.setAttribute('aria-label', 'Notifications');
|
||||||
|
this.button.setAttribute('title', 'Notifications');
|
||||||
|
this.button.innerHTML = `
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||||
|
</svg>
|
||||||
|
<span class="notif-badge" style="display:none">0</span>
|
||||||
|
`;
|
||||||
|
this.button.addEventListener('click', () => this.toggle());
|
||||||
|
|
||||||
|
const headerInfo = document.querySelector('.header-info');
|
||||||
|
if (headerInfo) {
|
||||||
|
headerInfo.prepend(this.button);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
createPanel() {
|
||||||
|
this.panel = document.createElement('div');
|
||||||
|
this.panel.className = 'notif-panel';
|
||||||
|
this.panel.setAttribute('role', 'region');
|
||||||
|
this.panel.setAttribute('aria-label', 'Notification history');
|
||||||
|
this.panel.innerHTML = `
|
||||||
|
<div class="notif-panel-header">
|
||||||
|
<span>Notifications</span>
|
||||||
|
<div class="notif-panel-actions">
|
||||||
|
<button class="notif-mark-read" title="Mark all read">Mark read</button>
|
||||||
|
<button class="notif-clear" title="Clear all">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="notif-panel-body"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.panel.querySelector('.notif-mark-read').addEventListener('click', () => {
|
||||||
|
this.notifications.forEach(n => n.read = true);
|
||||||
|
this.unreadCount = 0;
|
||||||
|
this.updateBadge();
|
||||||
|
this.renderList();
|
||||||
|
this.saveToStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.panel.querySelector('.notif-clear').addEventListener('click', () => {
|
||||||
|
this.notifications = [];
|
||||||
|
this.unreadCount = 0;
|
||||||
|
this.updateBadge();
|
||||||
|
this.renderList();
|
||||||
|
this.saveToStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(this.panel);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (this.isOpen && !this.panel.contains(e.target) && !this.button.contains(e.target)) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interceptEvents() {
|
||||||
|
// Listen for toast events to capture as notifications
|
||||||
|
const origInfo = console.info;
|
||||||
|
console.info = (...args) => {
|
||||||
|
origInfo.apply(console, args);
|
||||||
|
const msg = args.map(String).join(' ');
|
||||||
|
// Only capture app-relevant messages
|
||||||
|
if (msg.includes('[WS-') || msg.includes('Backend') || msg.includes('Service worker') ||
|
||||||
|
msg.includes('connected') || msg.includes('initialized') || msg.includes('sensing')) {
|
||||||
|
this.add(msg, 'info');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const origWarn = console.warn;
|
||||||
|
console.warn = (...args) => {
|
||||||
|
origWarn.apply(console, args);
|
||||||
|
const msg = args.map(String).join(' ');
|
||||||
|
if (msg.includes('Backend') || msg.includes('unavailable') || msg.includes('[WS-') ||
|
||||||
|
msg.includes('connection') || msg.includes('timeout')) {
|
||||||
|
this.add(msg, 'warning');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const origError = console.error;
|
||||||
|
console.error = (...args) => {
|
||||||
|
origError.apply(console, args);
|
||||||
|
const msg = args.map(String).join(' ');
|
||||||
|
if (msg.includes('Failed') || msg.includes('Error') || msg.includes('error')) {
|
||||||
|
this.add(msg, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
add(message, type = 'info') {
|
||||||
|
const notification = {
|
||||||
|
id: Date.now() + Math.random(),
|
||||||
|
message: this.truncate(message, 200),
|
||||||
|
type,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
read: false
|
||||||
|
};
|
||||||
|
|
||||||
|
this.notifications.unshift(notification);
|
||||||
|
if (this.notifications.length > this.maxNotifications) {
|
||||||
|
this.notifications.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.unreadCount++;
|
||||||
|
this.updateBadge();
|
||||||
|
this.saveToStorage();
|
||||||
|
|
||||||
|
if (this.isOpen) {
|
||||||
|
this.renderList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.isOpen ? this.close() : this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.isOpen = true;
|
||||||
|
this.panel.classList.add('open');
|
||||||
|
this.renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.isOpen = false;
|
||||||
|
this.panel.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList() {
|
||||||
|
const body = this.panel.querySelector('.notif-panel-body');
|
||||||
|
if (this.notifications.length === 0) {
|
||||||
|
body.innerHTML = '<div class="notif-empty">No notifications</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = this.notifications.map(n => {
|
||||||
|
const time = new Date(n.time);
|
||||||
|
const ago = this.timeAgo(time);
|
||||||
|
return `
|
||||||
|
<div class="notif-item notif-${n.type} ${n.read ? 'read' : 'unread'}">
|
||||||
|
<div class="notif-item-dot"></div>
|
||||||
|
<div class="notif-item-content">
|
||||||
|
<span class="notif-item-msg">${this.escapeHtml(n.message)}</span>
|
||||||
|
<span class="notif-item-time">${ago}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBadge() {
|
||||||
|
const badge = this.button?.querySelector('.notif-badge');
|
||||||
|
if (!badge) return;
|
||||||
|
if (this.unreadCount > 0) {
|
||||||
|
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
|
||||||
|
badge.style.display = '';
|
||||||
|
} else {
|
||||||
|
badge.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeAgo(date) {
|
||||||
|
const seconds = Math.floor((new Date() - date) / 1000);
|
||||||
|
if (seconds < 60) return 'just now';
|
||||||
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||||
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
truncate(str, max) {
|
||||||
|
return str.length > max ? str.slice(0, max) + '...' : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = text;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadFromStorage() {
|
||||||
|
try {
|
||||||
|
const data = sessionStorage.getItem(this.storageKey);
|
||||||
|
if (data) {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
this.notifications = parsed.notifications || [];
|
||||||
|
this.unreadCount = parsed.unreadCount || 0;
|
||||||
|
}
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
saveToStorage() {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(this.storageKey, JSON.stringify({
|
||||||
|
notifications: this.notifications.slice(0, 20),
|
||||||
|
unreadCount: this.unreadCount
|
||||||
|
}));
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.close();
|
||||||
|
this.button?.remove();
|
||||||
|
this.panel?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// Onboarding Tour - Interactive first-run walkthrough
|
||||||
|
// Shows on first visit, can be re-triggered from command palette or help
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ruview-onboarding-done';
|
||||||
|
|
||||||
|
export class Onboarding {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app;
|
||||||
|
this.overlay = null;
|
||||||
|
this.currentStep = 0;
|
||||||
|
this.steps = [];
|
||||||
|
this.active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.defineSteps();
|
||||||
|
document.addEventListener('start-onboarding', () => this.start());
|
||||||
|
|
||||||
|
// Auto-start on first visit
|
||||||
|
if (!this.isDone()) {
|
||||||
|
// Delay to let the app render first
|
||||||
|
setTimeout(() => this.start(), 800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineSteps() {
|
||||||
|
this.steps = [
|
||||||
|
{
|
||||||
|
title: 'Welcome to RuView',
|
||||||
|
text: 'WiFi-based human pose estimation that works through walls. Let\'s take a quick tour of the dashboard.',
|
||||||
|
target: null, // No highlight, centered
|
||||||
|
position: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'System Status',
|
||||||
|
text: 'Monitor your WiFi sensing hardware and API server status in real time. Green means everything is connected.',
|
||||||
|
target: '.live-status-panel',
|
||||||
|
position: 'bottom'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Live Demo',
|
||||||
|
text: 'Switch to the Live Demo tab to see real-time pose detection. Connect an ESP32 sensor or use the built-in simulation.',
|
||||||
|
target: '[data-tab="demo"]',
|
||||||
|
position: 'bottom'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Sensing Visualization',
|
||||||
|
text: 'The Sensing tab shows a 3D Gaussian splat visualization of WiFi signal fields, with real-time metrics.',
|
||||||
|
target: '[data-tab="sensing"]',
|
||||||
|
position: 'bottom'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Keyboard Shortcuts',
|
||||||
|
text: 'Press ? for shortcuts, Ctrl+K for the command palette, or use number keys 1-8 to switch tabs quickly.',
|
||||||
|
target: null,
|
||||||
|
position: 'center'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'You\'re all set!',
|
||||||
|
text: 'Explore the dashboard, connect hardware, or start the demo. You can replay this tour anytime from the command palette.',
|
||||||
|
target: null,
|
||||||
|
position: 'center'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
isDone() {
|
||||||
|
try { return localStorage.getItem(STORAGE_KEY) === 'true'; }
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
markDone() {
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, 'true'); }
|
||||||
|
catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.currentStep = 0;
|
||||||
|
this.active = true;
|
||||||
|
this.createOverlay();
|
||||||
|
this.showStep();
|
||||||
|
}
|
||||||
|
|
||||||
|
createOverlay() {
|
||||||
|
// Remove existing if any
|
||||||
|
this.removeOverlay();
|
||||||
|
|
||||||
|
this.overlay = document.createElement('div');
|
||||||
|
this.overlay.className = 'onboarding-overlay';
|
||||||
|
this.overlay.setAttribute('role', 'dialog');
|
||||||
|
this.overlay.setAttribute('aria-label', 'Onboarding tour');
|
||||||
|
this.overlay.setAttribute('aria-modal', 'true');
|
||||||
|
document.body.appendChild(this.overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
showStep() {
|
||||||
|
if (this.currentStep >= this.steps.length) {
|
||||||
|
this.finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = this.steps[this.currentStep];
|
||||||
|
const total = this.steps.length;
|
||||||
|
const isFirst = this.currentStep === 0;
|
||||||
|
const isLast = this.currentStep === total - 1;
|
||||||
|
|
||||||
|
// Clear highlight
|
||||||
|
document.querySelectorAll('.onboarding-highlight').forEach(el => el.classList.remove('onboarding-highlight'));
|
||||||
|
|
||||||
|
// Highlight target
|
||||||
|
let targetRect = null;
|
||||||
|
if (step.target) {
|
||||||
|
const targetEl = document.querySelector(step.target);
|
||||||
|
if (targetEl) {
|
||||||
|
targetEl.classList.add('onboarding-highlight');
|
||||||
|
targetRect = targetEl.getBoundingClientRect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.overlay.innerHTML = `
|
||||||
|
<div class="onboarding-backdrop"></div>
|
||||||
|
<div class="onboarding-tooltip ${step.position}" ${targetRect ? `style="${this.positionTooltip(targetRect, step.position)}"` : ''}>
|
||||||
|
<div class="onboarding-progress">
|
||||||
|
${Array.from({ length: total }, (_, i) =>
|
||||||
|
`<span class="onboarding-dot ${i === this.currentStep ? 'active' : i < this.currentStep ? 'done' : ''}"></span>`
|
||||||
|
).join('')}
|
||||||
|
</div>
|
||||||
|
<h3 class="onboarding-title">${step.title}</h3>
|
||||||
|
<p class="onboarding-text">${step.text}</p>
|
||||||
|
<div class="onboarding-actions">
|
||||||
|
<button class="onboarding-skip">Skip tour</button>
|
||||||
|
<div class="onboarding-nav">
|
||||||
|
${!isFirst ? '<button class="onboarding-prev">Back</button>' : ''}
|
||||||
|
<button class="onboarding-next">${isLast ? 'Get started' : 'Next'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Bind events
|
||||||
|
this.overlay.querySelector('.onboarding-skip').addEventListener('click', () => this.finish());
|
||||||
|
this.overlay.querySelector('.onboarding-next').addEventListener('click', () => {
|
||||||
|
this.currentStep++;
|
||||||
|
this.showStep();
|
||||||
|
});
|
||||||
|
const prevBtn = this.overlay.querySelector('.onboarding-prev');
|
||||||
|
if (prevBtn) {
|
||||||
|
prevBtn.addEventListener('click', () => {
|
||||||
|
this.currentStep--;
|
||||||
|
this.showStep();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.overlay.querySelector('.onboarding-backdrop').addEventListener('click', () => this.finish());
|
||||||
|
|
||||||
|
// Focus next button
|
||||||
|
this.overlay.querySelector('.onboarding-next').focus();
|
||||||
|
|
||||||
|
// Escape to close
|
||||||
|
this._escHandler = (e) => { if (e.key === 'Escape') this.finish(); };
|
||||||
|
document.addEventListener('keydown', this._escHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
positionTooltip(rect, position) {
|
||||||
|
const margin = 12;
|
||||||
|
if (position === 'bottom') {
|
||||||
|
return `left: ${Math.max(16, rect.left + rect.width / 2 - 180)}px; top: ${rect.bottom + margin}px;`;
|
||||||
|
}
|
||||||
|
if (position === 'top') {
|
||||||
|
return `left: ${Math.max(16, rect.left + rect.width / 2 - 180)}px; bottom: ${window.innerHeight - rect.top + margin}px;`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
finish() {
|
||||||
|
this.active = false;
|
||||||
|
this.markDone();
|
||||||
|
this.removeOverlay();
|
||||||
|
document.querySelectorAll('.onboarding-highlight').forEach(el => el.classList.remove('onboarding-highlight'));
|
||||||
|
if (this._escHandler) document.removeEventListener('keydown', this._escHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeOverlay() {
|
||||||
|
if (this.overlay?.parentNode) {
|
||||||
|
this.overlay.parentNode.removeChild(this.overlay);
|
||||||
|
this.overlay = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// Performance Monitor Overlay
|
||||||
|
// Shows FPS, memory usage, and network latency in real-time
|
||||||
|
|
||||||
|
export class PerfMonitor {
|
||||||
|
constructor() {
|
||||||
|
this.visible = false;
|
||||||
|
this.panel = null;
|
||||||
|
this.frames = [];
|
||||||
|
this.lastFrameTime = 0;
|
||||||
|
this.rafId = null;
|
||||||
|
this.latencyHistory = [];
|
||||||
|
this.maxHistory = 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createPanel();
|
||||||
|
document.addEventListener('toggle-perf-monitor', () => this.toggle());
|
||||||
|
}
|
||||||
|
|
||||||
|
createPanel() {
|
||||||
|
this.panel = document.createElement('div');
|
||||||
|
this.panel.className = 'perf-monitor';
|
||||||
|
this.panel.setAttribute('role', 'status');
|
||||||
|
this.panel.setAttribute('aria-label', 'Performance monitor');
|
||||||
|
this.panel.innerHTML = `
|
||||||
|
<div class="perf-header">
|
||||||
|
<span>PERF</span>
|
||||||
|
<button class="perf-close" aria-label="Close performance monitor">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="perf-metrics">
|
||||||
|
<div class="perf-row">
|
||||||
|
<span class="perf-label">FPS</span>
|
||||||
|
<span class="perf-value" data-metric="fps">--</span>
|
||||||
|
<canvas class="perf-spark" data-spark="fps" width="60" height="20"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="perf-row">
|
||||||
|
<span class="perf-label">MEM</span>
|
||||||
|
<span class="perf-value" data-metric="memory">--</span>
|
||||||
|
<canvas class="perf-spark" data-spark="memory" width="60" height="20"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="perf-row">
|
||||||
|
<span class="perf-label">LAT</span>
|
||||||
|
<span class="perf-value" data-metric="latency">--</span>
|
||||||
|
<canvas class="perf-spark" data-spark="latency" width="60" height="20"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="perf-row">
|
||||||
|
<span class="perf-label">DOM</span>
|
||||||
|
<span class="perf-value" data-metric="dom">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.panel.querySelector('.perf-close').addEventListener('click', () => this.hide());
|
||||||
|
|
||||||
|
// Make it draggable
|
||||||
|
this.makeDraggable();
|
||||||
|
|
||||||
|
document.body.appendChild(this.panel);
|
||||||
|
|
||||||
|
this.sparkData = {
|
||||||
|
fps: [],
|
||||||
|
memory: [],
|
||||||
|
latency: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
makeDraggable() {
|
||||||
|
const header = this.panel.querySelector('.perf-header');
|
||||||
|
let dragging = false;
|
||||||
|
let offsetX = 0;
|
||||||
|
let offsetY = 0;
|
||||||
|
|
||||||
|
header.addEventListener('mousedown', (e) => {
|
||||||
|
if (e.target.tagName === 'BUTTON') return;
|
||||||
|
dragging = true;
|
||||||
|
offsetX = e.clientX - this.panel.offsetLeft;
|
||||||
|
offsetY = e.clientY - this.panel.offsetTop;
|
||||||
|
header.style.cursor = 'grabbing';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
this.panel.style.left = `${e.clientX - offsetX}px`;
|
||||||
|
this.panel.style.top = `${e.clientY - offsetY}px`;
|
||||||
|
this.panel.style.right = 'auto';
|
||||||
|
this.panel.style.bottom = 'auto';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
dragging = false;
|
||||||
|
header.style.cursor = 'grab';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.visible ? this.hide() : this.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
show() {
|
||||||
|
this.panel.classList.add('visible');
|
||||||
|
this.visible = true;
|
||||||
|
this.lastFrameTime = performance.now();
|
||||||
|
this.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
this.panel.classList.remove('visible');
|
||||||
|
this.visible = false;
|
||||||
|
if (this.rafId) {
|
||||||
|
cancelAnimationFrame(this.rafId);
|
||||||
|
this.rafId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tick() {
|
||||||
|
if (!this.visible) return;
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
this.frames.push(now);
|
||||||
|
|
||||||
|
// Keep only last second of frames
|
||||||
|
while (this.frames.length > 0 && this.frames[0] < now - 1000) {
|
||||||
|
this.frames.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const fps = this.frames.length;
|
||||||
|
this.updateMetric('fps', fps, 'fps');
|
||||||
|
this.pushSpark('fps', fps, 0, 120);
|
||||||
|
|
||||||
|
// Memory (if available)
|
||||||
|
if (performance.memory) {
|
||||||
|
const mb = Math.round(performance.memory.usedJSHeapSize / (1024 * 1024));
|
||||||
|
const total = Math.round(performance.memory.jsHeapSizeLimit / (1024 * 1024));
|
||||||
|
this.updateMetric('memory', `${mb}MB`, mb > total * 0.8 ? 'warning' : 'ok');
|
||||||
|
this.pushSpark('memory', mb, 0, total);
|
||||||
|
} else {
|
||||||
|
this.updateMetric('memory', 'N/A', 'na');
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM node count
|
||||||
|
const domNodes = document.querySelectorAll('*').length;
|
||||||
|
this.updateMetric('dom', domNodes, domNodes > 3000 ? 'warning' : 'ok');
|
||||||
|
|
||||||
|
// Estimate latency from last navigation or resource timing
|
||||||
|
this.measureLatency();
|
||||||
|
|
||||||
|
this.rafId = requestAnimationFrame(() => this.tick());
|
||||||
|
}
|
||||||
|
|
||||||
|
measureLatency() {
|
||||||
|
const entries = performance.getEntriesByType('resource');
|
||||||
|
if (entries.length > 0) {
|
||||||
|
const last = entries[entries.length - 1];
|
||||||
|
const latency = Math.round(last.responseEnd - last.requestStart);
|
||||||
|
if (latency > 0 && latency < 30000) {
|
||||||
|
this.latencyHistory.push(latency);
|
||||||
|
if (this.latencyHistory.length > this.maxHistory) {
|
||||||
|
this.latencyHistory.shift();
|
||||||
|
}
|
||||||
|
const avg = Math.round(
|
||||||
|
this.latencyHistory.reduce((a, b) => a + b, 0) / this.latencyHistory.length
|
||||||
|
);
|
||||||
|
this.updateMetric('latency', `${avg}ms`, avg > 500 ? 'warning' : 'ok');
|
||||||
|
this.pushSpark('latency', avg, 0, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMetric(metric, value, status) {
|
||||||
|
const el = this.panel.querySelector(`[data-metric="${metric}"]`);
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = value;
|
||||||
|
el.className = `perf-value perf-${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushSpark(name, value, min, max) {
|
||||||
|
const data = this.sparkData[name];
|
||||||
|
if (!data) return;
|
||||||
|
data.push(value);
|
||||||
|
if (data.length > 60) data.shift();
|
||||||
|
this.drawSpark(name, data, min, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawSpark(name, data, min, max) {
|
||||||
|
const canvas = this.panel.querySelector(`[data-spark="${name}"]`);
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
if (data.length < 2) return;
|
||||||
|
|
||||||
|
const range = max - min || 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = 'rgba(50, 184, 198, 0.8)';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
|
||||||
|
data.forEach((val, i) => {
|
||||||
|
const x = (i / (data.length - 1)) * w;
|
||||||
|
const y = h - ((val - min) / range) * h;
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.hide();
|
||||||
|
if (this.panel?.parentNode) {
|
||||||
|
this.panel.parentNode.removeChild(this.panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// Quick Settings Panel - Centralized configuration for all UI features
|
||||||
|
// Accessible via gear icon in header
|
||||||
|
|
||||||
|
export class QuickSettings {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app;
|
||||||
|
this.button = null;
|
||||||
|
this.panel = null;
|
||||||
|
this.isOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createButton();
|
||||||
|
this.createPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
createButton() {
|
||||||
|
this.button = document.createElement('button');
|
||||||
|
this.button.className = 'settings-gear';
|
||||||
|
this.button.setAttribute('aria-label', 'Settings');
|
||||||
|
this.button.setAttribute('title', 'Quick settings');
|
||||||
|
this.button.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
|
||||||
|
|
||||||
|
this.button.addEventListener('click', () => this.toggle());
|
||||||
|
|
||||||
|
const headerInfo = document.querySelector('.header-info');
|
||||||
|
if (headerInfo) headerInfo.appendChild(this.button);
|
||||||
|
}
|
||||||
|
|
||||||
|
createPanel() {
|
||||||
|
this.panel = document.createElement('div');
|
||||||
|
this.panel.className = 'quick-settings-panel';
|
||||||
|
this.panel.setAttribute('role', 'dialog');
|
||||||
|
this.panel.setAttribute('aria-label', 'Quick settings');
|
||||||
|
|
||||||
|
this.panel.innerHTML = `
|
||||||
|
<div class="qs-header">
|
||||||
|
<h3>Settings</h3>
|
||||||
|
<button class="qs-close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="qs-body">
|
||||||
|
<div class="qs-section">
|
||||||
|
<div class="qs-section-title">Display</div>
|
||||||
|
<label class="qs-toggle">
|
||||||
|
<span>Reduced motion</span>
|
||||||
|
<input type="checkbox" id="qs-reduced-motion" ${this.prefersReducedMotion() ? 'checked' : ''}>
|
||||||
|
<span class="qs-switch"></span>
|
||||||
|
</label>
|
||||||
|
<label class="qs-toggle">
|
||||||
|
<span>High contrast</span>
|
||||||
|
<input type="checkbox" id="qs-high-contrast">
|
||||||
|
<span class="qs-switch"></span>
|
||||||
|
</label>
|
||||||
|
<label class="qs-toggle">
|
||||||
|
<span>Compact mode</span>
|
||||||
|
<input type="checkbox" id="qs-compact" ${this.getSetting('compact') ? 'checked' : ''}>
|
||||||
|
<span class="qs-switch"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="qs-section">
|
||||||
|
<div class="qs-section-title">Monitoring</div>
|
||||||
|
<label class="qs-toggle">
|
||||||
|
<span>Health polling</span>
|
||||||
|
<input type="checkbox" id="qs-health-polling" checked>
|
||||||
|
<span class="qs-switch"></span>
|
||||||
|
</label>
|
||||||
|
<label class="qs-toggle">
|
||||||
|
<span>Auto-reconnect</span>
|
||||||
|
<input type="checkbox" id="qs-auto-reconnect" checked>
|
||||||
|
<span class="qs-switch"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="qs-section">
|
||||||
|
<div class="qs-section-title">Data</div>
|
||||||
|
<div class="qs-row">
|
||||||
|
<span>Clear local data</span>
|
||||||
|
<button class="qs-btn-danger" id="qs-clear-data">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="qs-row">
|
||||||
|
<span>Reset onboarding</span>
|
||||||
|
<button class="qs-btn" id="qs-reset-tour">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Bind events
|
||||||
|
this.panel.querySelector('.qs-close').addEventListener('click', () => this.close());
|
||||||
|
|
||||||
|
this.panel.querySelector('#qs-reduced-motion').addEventListener('change', (e) => {
|
||||||
|
document.body.classList.toggle('reduced-motion', e.target.checked);
|
||||||
|
this.saveSetting('reduced-motion', e.target.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.panel.querySelector('#qs-high-contrast').addEventListener('change', (e) => {
|
||||||
|
document.body.classList.toggle('high-contrast', e.target.checked);
|
||||||
|
this.saveSetting('high-contrast', e.target.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.panel.querySelector('#qs-compact').addEventListener('change', (e) => {
|
||||||
|
document.body.classList.toggle('compact-mode', e.target.checked);
|
||||||
|
this.saveSetting('compact', e.target.checked);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.panel.querySelector('#qs-health-polling').addEventListener('change', (e) => {
|
||||||
|
const healthService = this.app?.components?.dashboard?.healthSubscription;
|
||||||
|
if (e.target.checked) {
|
||||||
|
// Resume would need import - just dispatch event
|
||||||
|
document.dispatchEvent(new CustomEvent('health-polling-toggle', { detail: true }));
|
||||||
|
} else {
|
||||||
|
document.dispatchEvent(new CustomEvent('health-polling-toggle', { detail: false }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
|
||||||
|
try {
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
} catch { /* noop */ }
|
||||||
|
this.close();
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.panel.querySelector('#qs-reset-tour').addEventListener('click', () => {
|
||||||
|
try { localStorage.removeItem('ruview-onboarding-done'); } catch { /* noop */ }
|
||||||
|
this.close();
|
||||||
|
document.dispatchEvent(new CustomEvent('start-onboarding'));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(this.panel);
|
||||||
|
|
||||||
|
// Close on outside click
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (this.isOpen && !this.panel.contains(e.target) && !this.button.contains(e.target)) {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply saved settings on init
|
||||||
|
this.applySavedSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
applySavedSettings() {
|
||||||
|
if (this.getSetting('reduced-motion') || this.prefersReducedMotion()) {
|
||||||
|
document.body.classList.add('reduced-motion');
|
||||||
|
const cb = this.panel.querySelector('#qs-reduced-motion');
|
||||||
|
if (cb) cb.checked = true;
|
||||||
|
}
|
||||||
|
if (this.getSetting('high-contrast')) {
|
||||||
|
document.body.classList.add('high-contrast');
|
||||||
|
const cb = this.panel.querySelector('#qs-high-contrast');
|
||||||
|
if (cb) cb.checked = true;
|
||||||
|
}
|
||||||
|
if (this.getSetting('compact')) {
|
||||||
|
document.body.classList.add('compact-mode');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prefersReducedMotion() {
|
||||||
|
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.isOpen ? this.close() : this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
open() {
|
||||||
|
this.isOpen = true;
|
||||||
|
this.panel.classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.isOpen = false;
|
||||||
|
this.panel.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
getSetting(key) {
|
||||||
|
try { return JSON.parse(localStorage.getItem(`ruview-setting-${key}`)); }
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSetting(key, value) {
|
||||||
|
try { localStorage.setItem(`ruview-setting-${key}`, JSON.stringify(value)); }
|
||||||
|
catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.button?.remove();
|
||||||
|
this.panel?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Hash Router - Makes tabs bookmarkable and shareable
|
||||||
|
// URL format: #dashboard, #demo, #sensing, etc.
|
||||||
|
|
||||||
|
export class Router {
|
||||||
|
constructor(app) {
|
||||||
|
this.app = app;
|
||||||
|
this.validTabs = ['dashboard', 'hardware', 'demo', 'architecture', 'performance', 'applications', 'sensing', 'training'];
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Navigate to hash on load
|
||||||
|
this.onHashChange();
|
||||||
|
|
||||||
|
// Listen for hash changes (back/forward navigation)
|
||||||
|
window.addEventListener('hashchange', () => this.onHashChange());
|
||||||
|
|
||||||
|
// Update hash when tab changes
|
||||||
|
const tabManager = this.app?.getComponent?.('tabManager');
|
||||||
|
if (tabManager) {
|
||||||
|
tabManager.onTabChange((tabId) => {
|
||||||
|
this.setHash(tabId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onHashChange() {
|
||||||
|
const hash = window.location.hash.replace('#', '').toLowerCase();
|
||||||
|
if (hash && this.validTabs.includes(hash)) {
|
||||||
|
const tabManager = this.app?.getComponent?.('tabManager');
|
||||||
|
if (tabManager && tabManager.getActiveTab() !== hash) {
|
||||||
|
tabManager.switchToTab(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setHash(tabId) {
|
||||||
|
// Only update if different to avoid infinite loop
|
||||||
|
const current = window.location.hash.replace('#', '');
|
||||||
|
if (current !== tabId) {
|
||||||
|
history.replaceState(null, '', `#${tabId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
// No explicit cleanup needed - event listeners are on window
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Screenshot Tool - Capture current tab view as PNG
|
||||||
|
// Uses html2canvas-like approach with native Canvas API
|
||||||
|
|
||||||
|
import { toastManager } from './toast.js';
|
||||||
|
|
||||||
|
export class ScreenshotTool {
|
||||||
|
constructor() {
|
||||||
|
this.capturing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
document.addEventListener('take-screenshot', () => this.capture());
|
||||||
|
}
|
||||||
|
|
||||||
|
async capture() {
|
||||||
|
if (this.capturing) return;
|
||||||
|
this.capturing = true;
|
||||||
|
|
||||||
|
const activeTab = document.querySelector('.tab-content.active');
|
||||||
|
if (!activeTab) {
|
||||||
|
toastManager.warning('No active tab to capture');
|
||||||
|
this.capturing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Flash effect
|
||||||
|
this.flashEffect();
|
||||||
|
|
||||||
|
// Try native ClipboardItem API first (modern browsers)
|
||||||
|
if (typeof ClipboardItem !== 'undefined') {
|
||||||
|
await this.captureToClipboard(activeTab);
|
||||||
|
toastManager.success('Screenshot copied to clipboard', { duration: 3000 });
|
||||||
|
} else {
|
||||||
|
// Fallback: download as file
|
||||||
|
await this.captureToFile(activeTab);
|
||||||
|
toastManager.success('Screenshot saved as file', { duration: 3000 });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Screenshot failed:', err);
|
||||||
|
// Fallback: capture visible canvases + basic layout
|
||||||
|
try {
|
||||||
|
await this.captureCanvasFallback(activeTab);
|
||||||
|
toastManager.success('Screenshot saved (canvas only)', { duration: 3000 });
|
||||||
|
} catch {
|
||||||
|
toastManager.error('Screenshot failed. Try using browser\'s built-in screenshot tool.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.capturing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async captureToClipboard(element) {
|
||||||
|
const canvas = await this.renderToCanvas(element);
|
||||||
|
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
||||||
|
await navigator.clipboard.write([
|
||||||
|
new ClipboardItem({ 'image/png': blob })
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async captureToFile(element) {
|
||||||
|
const canvas = await this.renderToCanvas(element);
|
||||||
|
const dataUrl = canvas.toDataURL('image/png');
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.download = `ruview-screenshot-${this.timestamp()}.png`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async captureCanvasFallback(element) {
|
||||||
|
// Find any canvas elements and merge them
|
||||||
|
const canvases = element.querySelectorAll('canvas');
|
||||||
|
if (canvases.length === 0) throw new Error('No canvas elements found');
|
||||||
|
|
||||||
|
const firstCanvas = canvases[0];
|
||||||
|
const mergedCanvas = document.createElement('canvas');
|
||||||
|
mergedCanvas.width = firstCanvas.width || 800;
|
||||||
|
mergedCanvas.height = firstCanvas.height || 600;
|
||||||
|
const ctx = mergedCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Dark background
|
||||||
|
ctx.fillStyle = '#1f2121';
|
||||||
|
ctx.fillRect(0, 0, mergedCanvas.width, mergedCanvas.height);
|
||||||
|
|
||||||
|
canvases.forEach(c => {
|
||||||
|
try { ctx.drawImage(c, 0, 0); } catch { /* tainted canvas */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add timestamp watermark
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||||
|
ctx.font = '12px monospace';
|
||||||
|
ctx.fillText(`RuView - ${new Date().toLocaleString()}`, 10, mergedCanvas.height - 10);
|
||||||
|
|
||||||
|
const dataUrl = mergedCanvas.toDataURL('image/png');
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = dataUrl;
|
||||||
|
link.download = `ruview-screenshot-${this.timestamp()}.png`;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async renderToCanvas(element) {
|
||||||
|
// Simple DOM-to-canvas renderer for basic content
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const scale = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = rect.width * scale;
|
||||||
|
canvas.height = rect.height * scale;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
|
||||||
|
// Render background
|
||||||
|
const styles = getComputedStyle(element);
|
||||||
|
ctx.fillStyle = styles.backgroundColor || '#1f2121';
|
||||||
|
ctx.fillRect(0, 0, rect.width, rect.height);
|
||||||
|
|
||||||
|
// Render existing canvases
|
||||||
|
const canvases = element.querySelectorAll('canvas');
|
||||||
|
canvases.forEach(c => {
|
||||||
|
const cRect = c.getBoundingClientRect();
|
||||||
|
const x = cRect.left - rect.left;
|
||||||
|
const y = cRect.top - rect.top;
|
||||||
|
try { ctx.drawImage(c, x, y, cRect.width, cRect.height); } catch { /* tainted */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render text content
|
||||||
|
ctx.fillStyle = styles.color || '#e0e0e0';
|
||||||
|
ctx.font = `14px ${styles.fontFamily || 'sans-serif'}`;
|
||||||
|
let textY = 30;
|
||||||
|
element.querySelectorAll('h2, h3, .stat-value, .metric-label').forEach(el => {
|
||||||
|
const text = el.textContent.trim();
|
||||||
|
if (text && textY < rect.height - 20) {
|
||||||
|
const elStyles = getComputedStyle(el);
|
||||||
|
ctx.font = `${elStyles.fontWeight} ${elStyles.fontSize} ${styles.fontFamily || 'sans-serif'}`;
|
||||||
|
ctx.fillStyle = elStyles.color;
|
||||||
|
ctx.fillText(text, 20, textY);
|
||||||
|
textY += parseInt(elStyles.fontSize) + 8;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watermark
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.15)';
|
||||||
|
ctx.font = '11px monospace';
|
||||||
|
ctx.fillText(`RuView - ${new Date().toLocaleString()}`, 10, rect.height - 10);
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
flashEffect() {
|
||||||
|
const flash = document.createElement('div');
|
||||||
|
flash.className = 'screenshot-flash';
|
||||||
|
document.body.appendChild(flash);
|
||||||
|
flash.addEventListener('animationend', () => flash.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp() {
|
||||||
|
return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Theme Toggle - Manual dark/light mode switch with persistence
|
||||||
|
|
||||||
|
export class ThemeToggle {
|
||||||
|
constructor() {
|
||||||
|
this.button = null;
|
||||||
|
this.currentTheme = this.getSavedTheme() || this.getSystemTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createButton();
|
||||||
|
this.applyTheme(this.currentTheme);
|
||||||
|
document.addEventListener('toggle-theme', () => this.toggle());
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||||
|
if (!this.getSavedTheme()) {
|
||||||
|
this.applyTheme(e.matches ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createButton() {
|
||||||
|
this.button = document.createElement('button');
|
||||||
|
this.button.className = 'theme-toggle';
|
||||||
|
this.button.setAttribute('aria-label', 'Toggle dark/light theme');
|
||||||
|
this.button.setAttribute('title', 'Toggle theme (T)');
|
||||||
|
this.updateIcon();
|
||||||
|
this.button.addEventListener('click', () => this.toggle());
|
||||||
|
|
||||||
|
// Insert into header
|
||||||
|
const headerInfo = document.querySelector('.header-info');
|
||||||
|
if (headerInfo) {
|
||||||
|
headerInfo.prepend(this.button);
|
||||||
|
} else {
|
||||||
|
const header = document.querySelector('.header');
|
||||||
|
if (header) header.appendChild(this.button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
this.currentTheme = this.currentTheme === 'dark' ? 'light' : 'dark';
|
||||||
|
this.applyTheme(this.currentTheme);
|
||||||
|
this.saveTheme(this.currentTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme(theme) {
|
||||||
|
this.currentTheme = theme;
|
||||||
|
document.documentElement.setAttribute('data-color-scheme', theme);
|
||||||
|
this.updateIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateIcon() {
|
||||||
|
if (!this.button) return;
|
||||||
|
const isDark = this.currentTheme === 'dark';
|
||||||
|
this.button.innerHTML = isDark
|
||||||
|
? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>'
|
||||||
|
: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>';
|
||||||
|
this.button.setAttribute('aria-label', isDark ? 'Switch to light theme' : 'Switch to dark theme');
|
||||||
|
}
|
||||||
|
|
||||||
|
getSystemTheme() {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
getSavedTheme() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem('ruview-theme');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTheme(theme) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('ruview-theme', theme);
|
||||||
|
} catch {
|
||||||
|
// localStorage not available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.button?.parentNode) {
|
||||||
|
this.button.parentNode.removeChild(this.button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// Enhanced Toast Notification System
|
||||||
|
// Supports multiple types: success, error, warning, info
|
||||||
|
// Stacking, auto-dismiss, manual close, progress bar
|
||||||
|
|
||||||
|
export class ToastManager {
|
||||||
|
constructor() {
|
||||||
|
this.container = null;
|
||||||
|
this.toasts = [];
|
||||||
|
this.idCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.container = document.createElement('div');
|
||||||
|
this.container.className = 'toast-container';
|
||||||
|
this.container.setAttribute('role', 'region');
|
||||||
|
this.container.setAttribute('aria-label', 'Notifications');
|
||||||
|
this.container.setAttribute('aria-live', 'polite');
|
||||||
|
document.body.appendChild(this.container);
|
||||||
|
}
|
||||||
|
|
||||||
|
show(message, options = {}) {
|
||||||
|
const {
|
||||||
|
type = 'info',
|
||||||
|
duration = 5000,
|
||||||
|
closable = true,
|
||||||
|
icon = null,
|
||||||
|
action = null
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const id = ++this.idCounter;
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
toast.setAttribute('role', 'alert');
|
||||||
|
toast.dataset.toastId = id;
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
success: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M13.5 4.5L6 12L2.5 8.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
||||||
|
error: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
|
||||||
|
warning: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 5v4M8 11h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M7.13 2.22L1.09 12.5a1 1 0 00.87 1.5h12.08a1 1 0 00.87-1.5L8.87 2.22a1 1 0 00-1.74 0z" stroke="currentColor" stroke-width="1.5"/></svg>',
|
||||||
|
info: '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6.5" stroke="currentColor" stroke-width="1.5"/><path d="M8 7v4M8 5h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>'
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayIcon = icon || iconMap[type] || iconMap.info;
|
||||||
|
|
||||||
|
toast.innerHTML = `
|
||||||
|
<div class="toast-icon">${displayIcon}</div>
|
||||||
|
<div class="toast-content">
|
||||||
|
<span class="toast-message">${this.escapeHtml(message)}</span>
|
||||||
|
${action ? `<button class="toast-action">${this.escapeHtml(action.label)}</button>` : ''}
|
||||||
|
</div>
|
||||||
|
${closable ? '<button class="toast-dismiss" aria-label="Dismiss">×</button>' : ''}
|
||||||
|
${duration > 0 ? '<div class="toast-progress"><div class="toast-progress-bar"></div></div>' : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Bind events
|
||||||
|
if (closable) {
|
||||||
|
toast.querySelector('.toast-dismiss').addEventListener('click', () => this.dismiss(id));
|
||||||
|
}
|
||||||
|
if (action?.onClick) {
|
||||||
|
toast.querySelector('.toast-action')?.addEventListener('click', () => {
|
||||||
|
action.onClick();
|
||||||
|
this.dismiss(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container.appendChild(toast);
|
||||||
|
|
||||||
|
// Trigger enter animation
|
||||||
|
requestAnimationFrame(() => toast.classList.add('toast-enter'));
|
||||||
|
|
||||||
|
// Auto-dismiss
|
||||||
|
let timeoutId = null;
|
||||||
|
if (duration > 0) {
|
||||||
|
const progressBar = toast.querySelector('.toast-progress-bar');
|
||||||
|
if (progressBar) {
|
||||||
|
progressBar.style.animationDuration = `${duration}ms`;
|
||||||
|
progressBar.classList.add('toast-progress-animate');
|
||||||
|
}
|
||||||
|
timeoutId = setTimeout(() => this.dismiss(id), duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause on hover
|
||||||
|
toast.addEventListener('mouseenter', () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
const bar = toast.querySelector('.toast-progress-bar');
|
||||||
|
if (bar) bar.style.animationPlayState = 'paused';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
toast.addEventListener('mouseleave', () => {
|
||||||
|
if (duration > 0) {
|
||||||
|
const bar = toast.querySelector('.toast-progress-bar');
|
||||||
|
if (bar) bar.style.animationPlayState = 'running';
|
||||||
|
timeoutId = setTimeout(() => this.dismiss(id), duration / 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.toasts.push({ id, toast, timeoutId });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(id) {
|
||||||
|
const index = this.toasts.findIndex(t => t.id === id);
|
||||||
|
if (index === -1) return;
|
||||||
|
|
||||||
|
const { toast, timeoutId } = this.toasts[index];
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
toast.classList.add('toast-exit');
|
||||||
|
toast.addEventListener('animationend', () => {
|
||||||
|
toast.remove();
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
this.toasts.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
success(message, options = {}) {
|
||||||
|
return this.show(message, { ...options, type: 'success' });
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message, options = {}) {
|
||||||
|
return this.show(message, { ...options, type: 'error', duration: options.duration || 8000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
warning(message, options = {}) {
|
||||||
|
return this.show(message, { ...options, type: 'warning', duration: options.duration || 6000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message, options = {}) {
|
||||||
|
return this.show(message, { ...options, type: 'info' });
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.toasts.forEach(({ timeoutId }) => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
});
|
||||||
|
this.toasts = [];
|
||||||
|
if (this.container?.parentNode) {
|
||||||
|
this.container.parentNode.removeChild(this.container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toastManager = new ToastManager();
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Uptime Clock - Shows system uptime and current time in header
|
||||||
|
|
||||||
|
export class UptimeClock {
|
||||||
|
constructor() {
|
||||||
|
this.widget = null;
|
||||||
|
this.startTime = Date.now();
|
||||||
|
this.intervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.createWidget();
|
||||||
|
this.update();
|
||||||
|
this.intervalId = setInterval(() => this.update(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
createWidget() {
|
||||||
|
this.widget = document.createElement('div');
|
||||||
|
this.widget.className = 'uptime-clock';
|
||||||
|
this.widget.setAttribute('aria-label', 'System uptime');
|
||||||
|
this.widget.innerHTML = `
|
||||||
|
<span class="uptime-time"></span>
|
||||||
|
<span class="uptime-separator">|</span>
|
||||||
|
<span class="uptime-duration" title="Session uptime"></span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const headerInfo = document.querySelector('.header-info');
|
||||||
|
if (headerInfo) {
|
||||||
|
headerInfo.appendChild(this.widget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (!this.widget) return;
|
||||||
|
|
||||||
|
// Current time
|
||||||
|
const now = new Date();
|
||||||
|
const time = now.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||||
|
this.widget.querySelector('.uptime-time').textContent = time;
|
||||||
|
|
||||||
|
// Uptime
|
||||||
|
const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
|
||||||
|
this.widget.querySelector('.uptime-duration').textContent = this.formatDuration(elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDuration(seconds) {
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
if (seconds < 3600) {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m}m ${s}s`;
|
||||||
|
}
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${h}h ${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
if (this.intervalId) clearInterval(this.intervalId);
|
||||||
|
this.widget?.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user